How to (Finally) Learn Test-Driven Development
If you’re new to software development, you’ve probably heard of test-driven development, or ‘TDD’, even if you don’t fully understand what it means. It’s a common best practice in the software industry, and most developers are expected to be comfortable with TDD as a way of working. Test-driven development is the practice of writing automated tests to drive out the design of your program.
If you’re like me when I was learning to code, getting a grip on TDD felt daunting. It took all my mental resources to write readable, working code even without tests. Trying to learn how to write good tests, or any tests at all, seemed to make life harder. As a result, I kept putting it off, thinking, I’ll learn TDD when I get more comfortable with programming.
In the mean-time, the Ruby on Rails applications I was building had started to get more complex. When I made a change to the code, I would sometimes introduce a bug in the process. The bug would go undetected, because I had no tests to help me know if I broke something. As I added more features to my apps, I became less confident that they worked as I intended. This lack of confidence in my code finally drove me to try TDD one more time.
Proponents of TDD argue that it can help you write better software by encouraging simple, durable design. This is true, but when you’re relatively new to programming, it can give you something even more valuable: confidence. When every part of your codebase is covered by a test, you can quickly verify that your program works as intended. If you accidentally break something, you’ll know straight away, thanks to a failing test. Though doing test-driven development can take longer upfront, it often means much less time spent debugging frustrating problems. A well-tested codebase means you can be much less timid when making changes. Even more importantly, TDD forces you to pause and think about your code before you write it. It also encourages a workflow that helps you to work in small, measurable steps, which can be really useful as a junior developer.
If you’d like to eventually get a job as a developer, learning test-driven development will be even more valuable for you. Many good companies expect new hires, even juniors, to have some familiarity with TDD. With competition for junior developer jobs only becoming more fierce over time, knowing TDD will help you stand out from other candidates.
In this post, I want to explain TDD as I wish I’d had it explained to me when I was struggling to learn it as a new developer. I hope this helps you finally grok TDD, even if you’ve failed to learn it before.
Red, Green, Refactor Explained
The hallmark of test-driven development as a practice is following the red, green, refactor workflow, often described as follows:
- Write a failing test.
- Make it pass.
- Refactor your code.
Your first question might be: how do I write a failing test? This is a good question, since it sounds like quite a strange thing to do. Why would I write code that is designed to fail?
In many cases, this failing test is meant to execute and verify code that doesn’t yet exist. Because of this, it will fail. The next step is to implement the code that will make the test pass.
Unit tests
Here’s a small example. Let’s say I’m making a little game in the Ruby programming language. (I’m not using a game development library like Gosu for this, just plain old Ruby.) When a ‘Player’ scores a goal, I want their @goals
instance variable to increase by 1
.
require 'player'
describe Player do
describe '#score_goal' do
it 'increments player goal tally by 1' do
player = Player.new
player.score_goal
expect(player.goals).to eq 1
end
end
At the time I write this test, the score_goal
method on the Player object doesn’t even exist! I’m writing a test based on how I want the code to work. When I run this test, it will quickly fail with a ‘No method’ error about the score_goal
method I’ve tried to call on the player object.
As a next step, I can implement this method, but make it do nothing at first.
class Player
attr_accessor :goals
def initialize(goals = 0)
@goals = goals
end
def score_goal
end
end
The next time we run the test we’ll get a better failure:
A good failure is a test that fails due to its expectation not being met, rather than due to a parse error inside the test. If possible, you should try to get to a ‘good’ failure before beginning to implement the functionality you are testing.
Now we can implement just enough code to make the test pass.
def score_goal
@goals = 1
end
We now have a passing test. Great! The final step in the process is refactoring, which means taking the time to make it easier to understand what the code does without changing what it does. In this case, the method is so simple that we probably don’t need a refactoring here, but often, you will.
But wait! You may have noticed that this code is problematic. If the same player scores another goal, the player’s goal count will remain stuck at 1
. This is a good thing, because it drives us to write another test that will, in turn, lead to a more robust implementation of our score_goal
feature.
describe Player do
describe '#score_goal' do
it 'increments player goal tally by 1' do
player = Player.new
player.score_goal
expect(player.goals).to eq 1
end
it 'adds to goal tally when additional goals are scored' do
player = Player.new
player.goals = 2
player.score_goal
expect(player.goals).to eq 3
end
end
end
A hat-trick! Almost there…
Now we can update our method with a better implementation, and both tests should still pass:
def score_goal
@goals += 1
end
The tests we’ve been writing so far are examples of unit tests. Unit tests exercise small bits of your application code in isolation.
A great way to practice test-driven development is to create a simple command line app, an app that runs in your Terminal (on OS X), or your Command Prompt (on Windows). Try to write unit tests for the app, from start to finish. Each time you want to add a behavior to the app, try to write a failing test first. Here are a few ideas for the kinds of apps you could create:
- A random generator for something (names, towns, characters)
- Tic Tac Toe
- A cash register
- An app to record things you’ve lent out and borrowed from friends
Once you’ve used test-driven development to design a command line application, your next challenge is applying these same principles to a web application.
Test-driven development on the web
Unit tests are just one kind of automated test, and are suited to almost all kinds of programs. Because web applications are complex, and often involve many pieces working together, other types of tests are often necessary to make sure that users are experiencing your software in the best possible way.
End-to-end tests
Also known as acceptance tests, integration tests, E2E tests.
There are two dominant approaches to this kind of testing:
A user-journey based approach. A user journey is a sequence of actions a user is likely to undertake when interacting with your application. You’ve likely already completed a few user journeys today! Examples are things like logging into your email, leaving a comment on Facebook, or making a transaction in your online banking account. Each application is made up of dozens, or hundreds, of potential user journeys.
User-journey based end-to-end tests try to simulate your app’s most important user journeys by controlling a browser (or a simulated browser) with code. Browser automation tools like Selenium actually open up a browser instance and trigger click events and other interactions on elements on the page, according to your instructions. Other E2E tests run in headless mode, where interactions with your app happen in the background and are not displayed via the GUI.
When writing end-to-end tests, you will be writing code to fill in forms, click buttons, and check that certain HTML elements are visible on the page. When a comprehensive suite of end-to-end tests runs successfully, you should be confident that all the most important user journeys in your app are hanging together.
Pros:
- Give you confidence that your app hangs together as a whole, and that no critical user journeys (like signing up, logging in, making a payment) are broken
Cons:
- Compared to other types of tests, which can be executed very quickly, end-to-end tests are among the slowest to run due to their need to simulate browser interactions.
Acceptance criteria based.
This type of end-to-end test is concerned with providing a final check that all the features in your application are working to spec. These will likely test functionality at a more detailed level than a user-journey based acceptance test. For example, rather than testing that a user can login and update their shopping cart, it might also test that all the items on a page have the correct tax rate applied.
While at the unit level you might test that a piece of functionality works in isolation, at the end-to-end test level, you test that it is also working correctly for users. The idea behind a test suite like this is that if all the tests pass, you should be able to confidently deploy your code to the live site.
Pros
- Provides an extra level of certainty that all your functionality works
Cons
- Likely to go into more detail than user-journey based tests and, as such, be somewhat slower
View tests
View tests have become more common with the rise of frameworks like Jasmine. View tests help you to ensure that all the pages in your application render correctly, by making assertions about the state of the page’s HTML structure given a set of circumstances and data. For example, you might verify that when you make a user’s profile data available to the view, that it is displayed in the way you expect, with the markup you expect. View tests will help you make sure that your pages look good to users, and let you know when something is missing that should be displayed (or displayed when it shouldn’t be!).
Pros
- Gives you a nice way to drive out features by asserting against what the user should see and experience in your app
Cons
- Can sometimes be a little tricky to manipulate and assert against HTML nodes rather than data directly
Controller tests
Across many web frameworks, particularly those that follow the MVC model, controllers are responsible for serving data to your views. The role of controllers is often contested among developers. Some view them as mere messengers that should be kept simple, their only task to pipe data from the backend API to the view with minimal interference and complication. Others view them as a powerful extra layer in your application, capable of performing necessary transformations on data before serving them up to the user.
Pros
- Help ensure that the data being passed to or received from the view is correct
Cons
- Some argue that controller tests should be limited because controller logic should be limited. This is commonly seen in the ‘fat models, skinny controllers’ argument
Integration tests
Integration tests sit at the level above unit tests. Let’s say you have a function that performs some complex transformations on data. You might have several unit tests to verify smaller functions you’ve written to help with the data transformation. An integration test could be used to check the final result of these functions working in tandem, to verify that the output of all your work is correct. Where unit tests strive to test things in isolation (a single method or function, for example), integration tests aim to test the end result of several functions, objects or classes working together to produce a result.
In part two of this series, I’ll dive into one of the most often misunderstood aspects of automated testing: mocks and stubs.
At the end of this series I hope you’ll have a good understanding of some of the key terms and methodologies in test-driven development, and the confidence to wrap solid tests around your code.