Talks

So writing tests feels painful. What now?

So writing tests feels painful. What now?

by Stephanie Minn

In the presentation titled "So writing tests feels painful. What now?" delivered at RailsConf 2024, Stephanie Minn addresses the common struggles software developers, especially in the Rails community, face when writing tests. She suggests that friction and pain experienced while testing could be indicators of a need for better software design.

Key points discussed include:
- Stages of Change Model: Minn relates stages of change to the testing experience, helping attendees identify where they stand in terms of improving their testing practices.
- Common Challenges in Testing: She highlights issues like stubbing multiple methods, dealing with confusing test data, and the tendency to force tests to pass without understanding their purpose.
- Working Example: A hypothetical application named "Patreon for pets" is used to illustrate concepts. The example involves classes handling tier upgrades and notifications, which complicate the testing process. The class interfaces are discussed, pointing out areas of confusion and dependencies on external classes, like the Stripe API.
- Mindset Shift in Testing: Minn argues for a shift in perspective where tests should not just pass but also clearly communicate how the code works for current and future developers. She highlights the importance of tests providing clarity, akin to leaving a torch in a dark tunnel.
- Refactoring for Clarity: After navigating through an overwhelming test, she emphasizes the necessity of breaking down complex functionalities into clearer components, thereby managing complexity better and making future changes easier.
- Code Smells and Trustworthy Tests: The session reinforces that if tests are painful, it often points to underlying code health issues. An approach highlighted is to focus on writing tests that are trustworthy to avoid fear of making changes in code.
- Collaboration and Feedback: Minn talks about the value of discussing code design with colleagues and utilizing collective wisdom to deepen understanding.
- Encouraging Small Changes: She encourages developers to make small steps towards improvement, such as reorganizing code or simplifying tests, to gradually foster a healthier codebase.

In conclusion, the talk emphasizes that the quality of tests reflects the quality of software design, urging developers to seek clarity and ease in their work. By acknowledging and addressing test pain, developers can not only improve their coding practices but also enhance their overall experience and effectiveness in building applications.

00:00:10.160 Welcome everyone, we're going to get started with our next session.
00:00:11.040 You might recognize this speaker's voice. Stephanie is the co-host of the Bike Shed podcast.
00:00:14.879 She's a Thoughtbot developer, and she has a great talk prepared for you today.
00:00:17.240 I'm honored to be introducing Stephanie Minn.
00:00:32.559 Hey everyone! Thanks so much, Kevin. I am so excited to be here today.
00:00:34.559 I'm very thankful to be in the first slot, so I can hopefully enjoy the rest of my conference without the anxiety of giving this talk.
00:00:40.399 So, we're just going to go for it!
00:00:43.799 I recently learned about something called the stages of change model. It describes how people navigate making significant decisions or changes in behavior in their lives.
00:00:52.600 I suspect my talk landed in this room because you are somewhere around the contemplation stage, considering making a change but might not know how to go about it.
00:00:59.519 In the determination stage, you may be ready to take action, starting with some small, approachable steps.
00:01:02.320 If you are indeed somewhere around these stages, then that is great for me because that means I have the best possible chance today to help you move forward from where you're at.
00:01:09.240 Can I get a show of hands if you or someone you know struggles with testing and you're not really sure how to make it better?
00:01:17.000 Yeah, okay, most people in this room.
00:01:18.000 Great! You're in the right place. If you're already a pro at this, know this talk is still for you. I think we would be wise to better understand and emphasize the experiences of our fellow colleagues.
00:01:25.280 As a consultant, I embed in client teams dealing with complexity in their day-to-day work, including the challenges in their tests.
00:01:30.679 The following may sound familiar: you read or write a test and get lost along the way. You find yourself bogged down, brute-forcing a test to green.
00:01:36.240 It makes sense that that feels painful, especially if you work in a large and complicated codebase.
00:01:41.600 What if I told you that you already have a tool for determining the next step?
00:01:43.400 Our instincts are wise, and we would do well to become more skilled at listening to them. My goal for this talk is not to prescribe solutions, but to encourage how you might explore your own process for navigating a tricky test.
00:01:49.519 Today, we'll work through an example realistic to many modern Rails apps I've seen. We'll articulate the reactions provoked by the code, learn how to adapt, and make space to experiment with change.
00:01:59.080 Imagine we're building an app called Patreon. Yeah, it's like Patreon but for pets.
00:02:02.399 It's a platform for people to financially support their favorite pets in exchange for membership perks.
00:02:08.360 This is my dog Hickory. He has a page on Patreon to cultivate a community of people who want to shower him with love, affection, and treats.
00:02:13.159 You can join the club on a certain tier, each of which comes with different membership benefits.
00:02:16.559 A reasonable $5 a month gets you access to an exclusive album of cute pictures of him, or you can go wild for the $100 tier.
00:02:21.600 I will personally cook him a steak dinner every month and you can FaceTime with him because why not?
00:02:26.319 Since I clearly know what the users want, I've also built the functionality to let Hickory fans try out benefits before committing.
00:02:32.679 You can upgrade tiers on a trial basis. This happens via a class called TierWithTrialUpgrader that accepts a membership and a tier in its constructor.
00:02:36.959 It has one public instance method called upgrade which starts the trial and updates the membership tier.
00:02:41.840 There's also some conditional logic if the membership is on a subscription-based billing model, it updates that subscription with the trial that was started.
00:02:44.800 This is perfectly serviceable code if you catch my drift.
00:02:49.680 In apps I've seen, classes like this will orchestrate procedural work. Maybe this code logs an event or enqueues additional tasks in a background job.
00:02:52.560 Later it turns out that subscription-based members are upset because they didn't get proper notification of their trial, and they want to know when they'll be billed next.
00:02:59.720 So we find ourselves looking inside this condition. Let's say we implemented a class called SubscriptionNotifier.
00:03:03.560 It has a class method named notifyTrialStarted that takes in a subscription and a trial. We can presume that subscription is defined elsewhere in this file.
00:03:09.240 We have trial as a variable from earlier in this method. Since we introduced new behavior, we would like to write a test for this.
00:03:13.800 For this talk, we are using RSpec Rails and Factory Bot as part of our testing toolkit.
00:03:15.680 We navigate to the spec for this class and notice there is an existing test for the method and even the condition that we are targeting.
00:03:17.640 Except it looks like this.
00:03:19.120 I find it pretty overwhelming. I can't even make sense of this slide.
00:03:22.760 Just don't even look at it.
00:03:25.479 Let me tell you what I experience when I look at this code. First, I want to make a noise, like something's wrong.
00:03:30.239 I don't know what to focus on. I am surprised to see names of collaborators that I didn't see at initial glance in the application code.
00:03:35.120 I'm confused about arbitrary values in the test data. I don't know what's important and what's not.
00:03:38.560 I enumerate these feelings because once I do, I tend to observe them with less attachment.
00:03:41.600 That helps me step into curiosity about what's going on rather than wallow in my grievances.
00:03:44.160 If you've heard of code smells, they are indications that something may be off about the design of your code.
00:03:46.240 Regardless of whether it works or even passes a test, developing a nose for code smells takes practice.
00:03:48.960 We already possess the instrument to pay attention to our own state of being.
00:03:51.000 Remember how I mentioned surprise at seeing additional collaborators? It turns out these two methods were obscuring some implicit dependencies.
00:03:58.039 A subscription is a memoized instance of the class Stripe::Subscription, which takes in a membership. It turns out the update method is called on the subscription.
00:04:03.240 It accepts the names of tiers it's moving to and from, as well as the end date for the trial. Don't ask me why, this is just what we're dealing with.
00:04:07.440 So our goal was to test the behavior we added. What now? The existing test was so difficult to read, and I suspect difficult to write, that we had to dip into details of private methods to understand what was going on.
00:04:15.839 When you write a test, you are interacting with the code as a user. It's said that if testing is painful, you may want to reconsider the design of your code.
00:04:20.160 That begs the question, what are tests for? Sure, they verify behavior and catch bugs, but if they give us grief and we don't know why, then I think a mindset shift is in order.
00:04:24.239 I'm of the opinion that we should write tests for ourselves and for each other, not merely for a green check mark. A good test tells you how the system works, how to use the methods available to you, what to put in and expect out, and any side effects that happen along the way.
00:04:31.480 Think of it like leaving a torch in a dark tunnel. We pay it forward when we write tests with the intention that others, including our future selves, will rely on them.
00:04:34.479 So we find ourselves in the dark tunnel of the TierWithTrialUpgrader class, a little lost and uncertain, and that's okay. It's not anyone's fault.
00:04:36.959 What matters is what we choose to do next, how we find our footing.
00:04:39.480 The main feeling I want to resolve is that overwhelm. How can I ground myself in this test file?
00:04:45.919 I typically begin by reorganizing information. I might collocate behaviors by inlining references that are declared far away, such as in blocks.
00:04:48.320 I might flatten or nest depending on what I need to situate the context of the conditions that are being tested.
00:04:51.080 In this case, let's start with a clean slate and write the test from scratch. Even though there's already an existing test for this code path, I would prefer not to be biased by what came before.
00:04:59.240 This is our test now, and we can do whatever we like.
00:05:02.639 You might feel nervous to do this, and that's totally okay.
00:05:04.639 It could mean you need a little bit more information before getting started with writing a test from scratch.
00:05:07.440 Common sense tells us to get directions before we head off into the unknown.
00:05:10.960 I suggest asking yourself a few questions.
00:05:13.040 Do you know what you're testing? Do you know how to set up the object and method under test? Do you know how to get into the appropriate code path?
00:05:16.160 If not, you may have a bit more work ahead.
00:05:22.920 So once I've gotten my bearings, I follow the arrange-act-assert pattern for organizing my tests.
00:05:25.680 This helps me keep track of where we've been and what we still need to do.
00:05:28.239 Sometimes I begin with the assertion, because this is information we should hopefully already have at hand.
00:05:32.239 We wrote it in our test description. In this case, we want to verify the behavior of the SubscriptionNotifier.
00:05:35.720 Since it is a side effect, and we don't have really a state to assert on, let's mock our class and expect it to receive the method.
00:05:40.239 Next, we construct an instance of TierWithTrialUpgrader to call upgrade on it.
00:05:43.600 It needs a membership and a tier, so we provide those two arguments by building them with Factory Bot.
00:05:46.080 Since we want to enter the condition of a membership with a subscription, we set the has_paid_subscription attribute to true.
00:05:50.080 Lately, I've been finding it helpful to think about necessary versus unnecessary coupling.
00:05:54.760 Coupling is the degree to which things need to change together.
00:05:57.520 You may know it as something to avoid, because loosely coupled modular software is easier to change.
00:06:00.600 Some amount of coupling is expected in a test, however, because we need an entry point into our system.
00:06:02.880 Considering the minimum dependencies required primes me to notice when coupling starts to unravel.
00:06:05.240 So, I think we would benefit a little bit from understanding where we're at now. So far, we've coupled ourselves to the class and method under test.
00:06:11.400 Luckily, upgrade doesn't accept any additional arguments, so we don't have to set up anything else.
00:06:14.640 The arguments to instantiate the TierWithTrialUpgrader are the membership and tier, as well as the details required to configure them to get into the right code path.
00:06:17.880 Lastly, the class responsible for the side effect, and for now, that all makes sense and seems reasonable to me.
00:06:21.360 However, we want to ensure that notifyTrialStarted is sent with the correct subscription and trial.
00:06:25.240 So we use our specs with a matcher to constrain our expectation.
00:06:29.160 Now we have a problem: how do we specify these arguments when we don't have access to them in our test?
00:06:31.040 Remember, a subscription is defined as a private method. So we instantiate it in our test.
00:06:35.760 Unfortunately, in order to make an assertion on it, we have to swap it in by stubbing the Stripe::Subscriptions.new method to return.
00:06:39.560 There we go. Our test looks like this now.
00:06:41.840 It grew a couple of lines, and I feel a bit uneasy about the wizardry we had to do in order to stub the subscription.
00:06:44.080 I want to make a note of that feeling, but for now, we'll move on because we still need to set up a trial.
00:06:47.600 If you'll recall, a trial is returned by a class method called start on the Trial model. So, we build a trial with a factory.
00:06:53.760 Again, we need to stub a class method in order to verify that the trial was returned and that it indeed gets passed to our Notifier.
00:06:56.560 This is where I began to juggle a few too many things in my head, but we are so close.
00:06:59.680 We are ready to run our test, and it fails spectacularly.
00:07:02.240 Why? It turns out that updating a subscription actually makes a request to the Stripe API.
00:07:05.120 That test failure can manifest in a couple of different ways. You might see an error from Stripe itself.
00:07:09.680 If your app uses Webmock, you'll see a complaint about external network requests.
00:07:12.239 Or worse, what we just saw, you might get an obtuse NilClass exception.
00:07:16.320 Because the code is so brittle, you simply can't run this test in isolation despite setting up everything in the class.
00:07:18.720 Anyone? Sorry, this clicker is a bit finicky.
00:07:24.360 Okay, so fine, we don't want to bother with subscription actually calling its update method in the test.
00:07:27.600 And we plug that up here with another allow statement.
00:07:31.120 And now, if you can believe it, we finally arrived at a green test.
00:07:34.080 Though honestly, at this point, I'm pretty mad. That kind of sucked!
00:07:36.240 I've lost a lot of my steam. When I'm in this mood, pessimism blocks my critical thinking.
00:07:39.760 When I try to push through it, I do worse work.
00:07:43.760 I would like to interact with the code from a place of calm and not frustration.
00:07:47.760 So now is an excellent time to take a break, grab a snack, and pet my dog.
00:07:51.480 Look at this guy! Don't you want to join his Patreon? Yeah, $100 to moan!
00:07:56.480 Anyone? Anyway, we come back to our test in its full glory a beat later, and it's passing.
00:07:59.760 That's great! Let's take a moment to celebrate what we've accomplished thus far.
00:08:02.480 In testing, the notifyTrialStarted was invoked with the correct arguments. We made explicit the data required for the method under test.
00:08:06.279 We unearthed some coupling to Stripe via the subscription model, and we learned how trials are started.
00:08:10.799 Working effectively with legacy code, Michael Feathers introduces the concept of a characterization test.
00:08:13.520 When writing tests for code you don't understand, the goal is not to figure out what it should do, but what it actually does.
00:08:18.040 When you write tests that way, testing no longer is a moral authority; it's just a tool for facilitating the gathering of information.
00:08:22.519 I like this framing because it encourages discovery as the primary goal.
00:08:26.640 Even though we had some existing coverage, writing the test from scratch illuminated our code for what it was.
00:08:30.240 Whether or not we liked it, that brings me to my next point: move at the speed of trust in your tests.
00:08:36.680 If you don't have trustworthy tests, it's no surprise to me that fear of change lurks in every file.
00:08:40.160 It's not enough for tests to fail if you don't understand what broke.
00:08:43.760 That's why I take the time to care for and restore tests. It's the quickest and safest way to observe the behavior of your application.
00:08:48.160 Now that I'm no longer overwhelmed, I'm more in tune with other types of friction.
00:08:52.320 Let's say I start having to scroll up and down in this test file just to navigate it.
00:08:55.479 That's kind of annoying, right? What started as a few lines has now taken up a lot of real estate.
00:09:00.200 In other words, this class has grown additional responsibilities.
00:09:03.720 It may not be suited for the upgrade method, which now has to start a trial.
00:09:07.080 Determine if the membership has a subscription, instantiate it, update it, send the notification, and update the membership as well.
00:09:11.760 Writing out responsibilities of classes and methods can be in a code comment or on a Post-it note.
00:09:15.040 This is a technique commonly used in domain-driven development, and I find it useful when I need to make sense of existing code.
00:09:19.880 Without that awareness, I'm not loving this change.
00:09:22.080 The class already manages a lot of tasks, and my experience tells me that code like this only attracts more responsibilities.
00:09:25.959 You see it, and you're just like, 'Ah, what's one more line?'
00:09:29.160 I'm not totally keen on merging this as is because I don't think my work is complete.
00:09:31.959 We've built up all this knowledge, so let's put it to good use.
00:09:35.120 For me, that's a cue to seek out opportunities for improvement. With our trusty test in place, we can begin refactoring safely.
00:09:40.799 Here's what our test descriptions currently look like.
00:09:43.679 Their setups end up being quite similar, and now is a good time to ask: why does this duplication exist?
00:09:46.280 In a previous life, before I was a dev, I went to journalism school where I learned the value of brevity.
00:09:50.680 I often find myself noticing redundancy.
00:09:54.760 I've highlighted all instances of the words subscription and trial. Is there a missing connection here?
00:09:57.240 Are these operations maybe that have to happen together?
00:10:00.760 I also want to follow up on the awkwardness I felt from allowing these messages.
00:10:03.640 By doing this, we wave a magic wand over these methods so that they're not actually exercised.
00:10:06.239 When these allowances make up the bulk of my test, I get dubious.
00:10:10.640 How do I know that my code is integrated properly when I've just allowed all these shortcuts?
00:10:13.880 That feeling should nudge you towards a more focused unit test.
00:10:17.760 I chose an example with excessive stubbing because I think it's a common cause of pain.
00:10:21.680 There's a different style of testing that involves creating all the objects needed to exercise the code fully.
00:10:24.520 Rather than stubbing the update method, we could have set up some more collaborators.
00:10:27.320 Having to create many objects and corresponding records in the test database is a symptom of a similar problem.
00:10:30.840 While we're taking a different approach here, be wary of mystery guests that show up to the party in your tests.
00:10:34.360 So my doubt kind of turns my attention to the code. Isn't it surprising how these mere two lines ended up generating so many more in the test?
00:10:37.680 That leads me to inline the update_subscription method. Now I can see clearly what's involved.
00:10:41.520 My eye catches on how we are exposing the tier_name method on subscription.
00:10:43.679 Wouldn't this be available from inside the instance method?
00:10:48.399 If you get lost or distracted by seemingly irrelevant method calls, that's a signal that there may simply be too much information.
00:10:51.240 I had made a point earlier to keep an eye on extraneous coupling.
00:10:55.840 The failing test we saw earlier also reinforced that concern. We found ourselves deep in the guts of code dealing with the structure of Stripe's subscription API.
00:11:03.240 I know a rabbit hole when I see one. This code not only leaks details about subscription, but tier and trial as well.
00:11:09.120 With all this laid out in front of me, I wonder if there's a meaning hidden for me to discern.
00:11:15.520 Can I distill this information into a single idea? Many people associate redundancy with applying "DRY": Do Not Repeat Yourself.
00:11:23.960 I find that rule quite lacking in nuance. The point is not just to remove duplication; DRY requires sitting with ambiguity until you are able to express the truest and most precise abstraction with your code.
00:11:30.000 Anything less risks obfuscating meaning, like we saw earlier. To be clear, this is really challenging. It also takes time.
00:11:34.760 I enjoy pairing with someone to talk through it. Doing so also develops a common language for the code you're working on together, which is critical for sharing knowledge across the team.
00:11:37.360 After giving it some thought, I landed on encapsulating the two responsibilities in a new method.
00:11:40.520 We sprouted the idea of updating a subscription specifically in the context of a trial, as well as wrapping it with the intention of notifying.
00:11:45.280 Hence the name, notify_update_with_trial.
00:11:49.720 This is another strategy I like for working effectively with legacy code.
00:11:53.760 Wrapping methods lets us add new functionality to existing methods without modifying them.
00:11:57.440 We've also introduced a new seam. A seam is a place that enables behavior to change.
00:12:01.520 The seam we've made here separates the class responsible for orchestrating the mechanisms of upgrading from how it happens.
00:12:05.760 This seam dovetails nicely with another guideline: Tell, Don't Ask.
00:12:10.000 Previously, the code exposed information about the tier subscription and notifier.
00:12:14.400 Instead of querying for those details, we simply tell the subscription to do a notified update.
00:12:18.000 The best part is that we can basically move our existing test into the spec file for Stripe::Subscription with a few minor adjustments.
00:12:21.280 That leaves us with only one test needed for that condition in TierWithTrialUpgrade, and for now, this is where we stop.
00:12:26.080 I've been sitting with this example for a month now, and I still see areas for improvement. But perfect is the enemy of good.
00:12:30.240 That's a task for another day. This is what the method looks like now.
00:12:35.679 You might be thinking that was a whole lot of effort for it to look almost exactly the same.
00:12:37.360 Was it worth it? I believe so. The only way to deal with complexity is to manage it.
00:12:41.360 We created an affordance for the emerging concepts related to subscription and we prevented this class from growing yet another line, another responsibility.
00:12:45.600 We learned a lot and documented those learnings as collective wisdom in our tests.
00:12:48.560 Building up that knowledge is crucial. It's only then we can shape change with creativity and clarity.
00:12:52.720 Michael Feathers says the biggest obstacle to improvement in large code bases is the existing code.
00:12:55.840 But I'm not talking about how hard it is to work with difficult code. I'm talking about what that code leads you to believe.
00:13:01.200 Code constantly provokes reactions in us, for better or for worse. It doesn't even need to be old; it can be code you wrote ten minutes ago.
00:13:05.000 How we feel when we write tests is one of the quickest feedback loops we have about the quality of our system.
00:13:07.840 If we ignore or numb ourselves to that pain, we risk losing faith in our work.
00:13:11.120 Or worse, we may be told to do the dreaded rewrite.
00:13:12.919 We need to find better ways of coping because that test pain matters.
00:13:17.000 Can you figure out what you need to do right now to attend to it?
00:13:21.240 It doesn't need to be a sweeping refactor; small actions add up.
00:13:25.239 I promise, even just reorganizing some stuff in a file, verifying the name of a method or variable, that helps you.
00:13:30.000 It helps others, and that's important.
00:13:31.920 So we can continue doing the good work of connecting pets and pet lovers on our obviously viable and profitable product, Patreon.
00:13:37.760 To call back to the stages of change model from the beginning, I would be thrilled if I were able to help you today.
00:13:42.480 Move even just a little bit forward towards determination and action in harnessing the power of your tests.
00:13:48.240 If you're ready to dive deeper, I learned, referenced, and found inspiration from many resources for this talk.
00:13:51.520 I'm excited to see a TDD for absolute beginners workshop on the schedule for tomorrow.
00:13:56.480 If you have yet to try test-driven development, I highly recommend it. It's a great skill to have in your testing toolkit.
00:14:02.480 All of the talks in this room today go really nicely with themes around software design.
00:14:06.560 I will be staying in this room for sure.
00:14:10.479 We talked some today about design patterns and heuristics.
00:14:14.640 In fact, object-oriented programming picked up the idea of design patterns from the art and science of architecture.
00:14:18.360 Specifically, the work of an architect named Christopher Alexander.
00:14:22.760 He wrote a book called The Timeless Way of Building that theorizes why design patterns work so well for us.
00:14:27.960 He says that the point is to invoke a quality without a name.
00:14:30.360 He illustrates this idea by distinguishing between the design of a courtyard that feels alive versus one that feels dead.
00:14:35.200 A living courtyard is one that supports life. Its design enables our natural walking patterns, connecting to open spaces.
00:14:38.883 In contrast, a dead courtyard is one whose design fails to provide for the needs of the people in it.
00:14:43.520 It might be completely enclosed or provide no shade.
00:14:46.880 I'm sure we've all been in a courtyard and thought, 'Oh, I don't know, I just want some fresh air, but I don't want to sit here.'
00:14:50.000 It's uninviting and, as a result, becomes abandoned.
00:14:53.240 Our human intuition can tell that difference when we inhabit the spaces, and the same is true of the code we work in.
00:14:57.000 The tech industry loves being data-driven, but I personally find inspiration in remembering the purpose of best practices beyond what's clean or superior.
00:15:01.280 It's something that no algorithm can quite capture.
00:15:04.760 Danella Meadows, in Thinking in Systems, says that pretending something doesn't exist, even if it's hard to quantify, leads to faulty models.
00:15:09.680 Human beings have been endowed not only with the ability to count but also the ability to assess quality.
00:15:13.640 So let's not lose sight of that quality without a name.
00:15:17.280 How we feel about our software.
00:15:21.360 Since you're already writing a test, ask yourself: what do you need to find some peace and ease in that work?
00:15:24.720 And then make it so.
00:15:28.160 I'm Stephanie Minn. I work at a consultancy called Thoughtbot. You can hire us to help your team write joyful, attainable tests.
00:15:37.760 I host a weekly podcast about software development called The Bike Shed. My co-host, Joel Kinville, who's sitting in the front row, is also giving a talk along with several other Thoughtbotters on Thursday.
00:15:41.960 So do check those out! I would love to know what else you need to attend to your test pain and change code with confidence.
00:15:45.360 If you have any feedback or thoughts, I will be around. Please feel free to come up and talk to me.
00:15:51.040 I would really love that. And then maybe one day I'll launch Patreon for real! Some early users in this room!
00:15:56.960 Yeah, and also maybe Hickory will learn to write my tests for me.
00:15:59.720 And make this talk totally moot. Who knows?
00:16:02.560 And that's it. Thanks so much!