How to Learn TDD When You’ve Failed Before
One of the biggest stumbling blocks for new programmers is going from writing code without tests to doing test-driven development (TDD). For most of us, the journey looks like this:
- Start learning how to code. You don’t write tests because you don’t know what they are, or that you should be writing them.
- You hear, somewhere, that you really should be writing tests. This happens around the time implementing one feature breaks an entirely unrelated feature, and you can’t work out why.
- You promise yourself you’ll learn testing soon, but for now, simply making apps that work is hard enough. Adding tests to the mix feels like learning to speak a foreign language from a book written in yet another foreign language.
- You start to feel like, without tests, your code isn’t good enough. If you can’t learn tests, this is where many new programmers get stuck (at least, it’s certainly where I got stuck).
When you aren’t testing, but know you should be, you enter a weird space where you enjoy programming but find it hard to feel proud of what you’ve created. And, without tests, making changes feels like throwing the dice. You don’t understand every inch of the things you build, therefore, unexpected side effects are frequent without tests.
As a former non-tester turned TDD apprentice, I feel confident in saying that you will enjoy programming more, and write better code, if you write tests. You’ll be following best practices, and will feel prouder of your work. You’ll be more confident making changes and adding new features, too.
Here are some tips that helped me make the transition after I’d failed many times before to learn TDD.
Don’t Test First, at First
No, that’s not a typo. Though you should start writing tests as soon as possible, you shouldn’t necessarily start writing them first. Instead, in the beginning, let the code you’ve already written and already understand guide the tests that you write.
The simplecov gem was immensely helpful for this. If you’re not a Rubyist, there is most likely an equivalent in your programming language of choice. Simplecov analyses your code and highlights lines in green if they’ve been adequately tested, and red if they haven’t. It also gives you a total percentage rating for your app’s test coverage (how much of your app is properly tested). You can use simplecov to work through your app, adding tests and increasing your percentage of coverage.
Though your chosen language might have a testing framework of choice, I’d recommend starting with its default testing framework (such as Minitest in Ruby). Whatever testing framework you end up specialising in, you’ll be expected to have an understanding of how your language’s default testing framework works. In the case of Minitest, the syntax is arguably easier to learn than Rspec’s, though harder to organise on more complex projects.
Get into the habit of writing a test as soon as you implement some new code.
As you write tests guided by your code, you’ll become comfortable with your testing framework’s syntax and develop an understanding of what makes a good test.
Next, switch to your language’s most popular testing framework. Now that you understand the fundamentals of testing, learning a new syntax shouldn’t be as brain-bending as it might have been in the past.
For your next project, try a proper test-first workflow. Expect to go really slow - that’s OK! The purpose of the project should be getting used to a test-first approach, even if this means you go more slowly at first. One of the core premises of TDD is that you spend a little extra time upfront to save yourself hours of stressful debugging down the road.
Try writing a failing integration test before you begin coding any features in your app. In Ruby, Capybara is the gem of choice for integration tests. The integration test can serve as a to-do list for functionality you need to implement. Making each part of the integration work will likely guide you down into unit tests. If you can stick with these good habits, you’ll be TDDing with the best of them.
One thing: for a long time I was confused by the meaning of unit tests vs. integration tests vs. controller tests, so here’s a quick breakdown that you might find useful.
Integration tests ensure that user flows hang together. They simulate some of the most common behaviours of users of your app, like signing in, making a purchase, creating a resource, or editing a comment. Integration tests let you know that everything is hanging together correctly. They focus on testing the controllers and views.
Integration tests in practice:
- Visit a URL
- Check that the heading we expect is displayed on the page
- Check that clicking a link on the page takes me where I expect
Unit tests ensure your app’s core logic is working correctly. Do the methods on your models return the expected values? Are you formatting names the way you want? Are the right things being saved to the database?
The best unit tests are modular - they test one thing, and don’t depend on anything outside of the test in order to work. That way, code changes won’t lead to unexpected failures of seemingly unrelated unit tests. Unit tests that are too interconnected with the different parts of your app are called ‘brittle’, meaning that they are easy to break as a side effect of changing unrelated code, or adding new features.
Unit tests in practice:
- Check that a user’s birth date is correctly formatted
- Ensure that a Booking is saved with the correct attributes
- Verify that a large list of user orders is sorted correctly
You may also hear about controller tests, though their utility is debated in the Ruby community. Controllers should have as little logic as possible. Therefore, integration tests are usually sufficient to ensure your views and controllers are working well together. Unless your controllers are doing some heavy lifting, unit tests and integration tests should be enough. And if your controllers are doing heavy lifting, the first question you should ask yourself is, should they be?