So you open your laptop, and the first thing you do, you go to the test, which is hopefully there, and you try to figure out what's failing, and what is the intention, how it's supposed to work. But if you have a lot of smart assertions to compute in your head, if you had a complicated testing setup, if you have this mutable-system result, you're gonna have a really hard time debugging all that. So you're gonna end up pissed, you're gonna slam your laptop, and you're gonna go to bed, and this is gonna be a horrible experience that you could've prevented.
And you can prevent it by keeping your tests flat. And here's an example for you. This is a typical test, so we have a describe block that wraps the whole feature, it prepares some environment before all tests, then it has a sub-feature, for example, and it has its own setup, and then finally, the test. Even at this simple example, notice how many things we need to keep in mind, just to understand what this single test needs. So why not just put it into the test itself and drop the describe blocks altogether? And I hear you, it's gonna be pretty confusing and repetitive at first, but in time, you will grow to love this, because the benefits this gives are just incredible. It's declarative, it's explicit and you understand what each test needs from the test, from reading the test. And then of course, you can create and reuse test utilities to abstract commonly used logic. For example, if in this test we fill in a signing form and we do this very often to test the signing feature, well, why not abstract it into helper utility and call it sign in? And notice how immediately this reads much better, it reads like the intention, we want to sign in with this credentials. It doesn't matter what are the form selectors, what are the ideas and classes, it doesn't matter, the intention is to sign in and then do some expectations.
And of course, one of the most overlooked feature or like approaches is that you can split tests, you don't have to stuff all the tests in a single test file. So if you have a complicated feature like this sign in and it has different providers like email and GitHub, well, put them into separate test files and it's going to give you great readability and discoverability for the price of zero. And then when you need to add more logic and more tests, just add new test files and that's it. The same stands true when deleting features, because just as good code, good test is the one you can easily delete. It's the test that doesn't introduce a lot of implicit dependencies and all sorts of magic in the setup, which makes it really hard to remove.
So to summarize, tackling complexity in tests, it's very important to use the test phases properly and to do the most heavy lifting on the setup phase. And then of course reduce repetition in action phase. It's really crucial to express intentions using helper functions like the signing function I just showed you to help your test read like specification instead of a bunch of implementation details. It's really good to keep test structure flat so perhaps putting everything a single test needs in a single test block and of course use simple explicit assertions so you don't have to compute a lot of things in your head to understand what the test does. And when it comes to complex features, well you can also split them on the file system level and gain this great discoverability and great maintenance over time as your product develops. Of course there's much more to complexity, but that's all I have for today, so make sure to follow me on Twitter if you liked this talk and share with me some of your experiences with how you dealt with complexity in tests in the past.
Comments