In my previous post we talked about mocks and stubs: what they are, and how to use them in various testing scenarios to give yourself more flexibility, speed up your tests, and get more determinism out of your test suite.
In this post, I’m going to cover two methods for software development that take testing into consideration at the outset: test-driven development (TDD) and behavior-driven development (BDD). Using these methodologies will improve the way you think about software development, and greatly enhance the efficacy of your tests.
Let’s dive in:
TDD: test-driven development
TDD (test-driven development) is known as a method for writing unit tests. In this post, we are going to talk about using TDD principles for everything from functional testing to unit testing.
Red-Green Refactor: test-driven development principles
With TDD, you design your code before you implement it. Therefore, TDD forces you to think about your components’ behavior before you write it. It’s also a great way to keep you focused on what you are trying to deliver. In TDD, you write tests for your method or implementation to test what that implementation should do.
Remember, with TDD, your test will always fail first. You haven’t written the code yet so there is no functionality. This is a good thing! It proves that your test won’t just pass any old implementation!
Next, it’s time for you to prove that your test will pass when the implementation is valid and it serves its purpose. Once you check that your test fails when the implementation doesn’t work correctly and passes when implementation does function correctly, you can refactor your code to be better and clearer. Since you already have the test right there, refactoring will be much easier and you can do it with the assurance that your tests will tell you whether you changed the code’s behavior successfully. This is called red-green refactoring.
Red: First, you make your test fail. Green: Then, make it pass.
Test first, refactor after. This ensures that your code is clean and production-ready. I want to emphasize: it’s important to actually go through red and green before you make your code perfect. The red stage will verify that your test is not just going to pass all the time. You can feel confident it’s deterministic later on when things get more complex. Later, in the green stage, your focus isn’t ‘How can I write the best code?’ but rather ‘How can I write code that meets the requirements?’ Think of this as the stage to prove that your test is passing for the valid use cases. In the next stage, refactoring, you will have a change to revisit your code. The refactoring stage is when you can write cleaner, more intelligent code, and make improvements. Often, engineers start writing in the beginning and lose focus of what they were supposed to deliver. Other times, engineers will write tests at the same time as they are creating the implementation and create unexpected bugs. TDD helps avoid those mistakes.
The test you write might look like this:
describe('sum()', function () {
it('should return the sum of given numbers', function () {
expect(simpleCalculator.sum(1,2)).to.equal(3);
expect(simpleCalculator.sum(5,5)).to.equal(10);
});
})
1. Red: Your implementation is currently empty. You haven’t implemented yet, so the tests will fail. You want to verify that your test is deterministic: it will tell you when it should fail or pass.
var Calculator = function () {
return true // implementation goes here
}
2. Green: Implement it. Make the test pass. Here we’ll write the code that will make the test pass.
var Calculator = function () {
return{
sum: function(number1, number2){
return number1 + number2;
}
};
}
Now your test will be satisfied, because we’ve added the function.
Refactoring code: Now, refactor your code to be clearer and more readable. The first two steps made it so that your test is reliable and you don’t have to worry about modifying your code’s behavior accidentally. Remember, you verified the functionality of your code with the test that went through the red and green stages. So once the code is in the refactoring stage, the tests should not be changed. If you make a change now, you increase the likelihood of a functionality failure in your source code. If you need to change the tests, make sure to do so in the red/green stage.
Once you have a valid test, you can refactor the code to be cleaner and more aligned with the style or overall class.
Putting it all together
The example above is for a unit test. But how can we use TDD on other layers of the test pyramid (for an in-depth explanation of this pyramid, see my previous post on testing)?
When I make an implementation, I’ll start with UI layer testing first (using BDD, which I’ll explain in the next section). Even though these UI tests will not pass for a long time, starting from the tests helps me focus on what I am actually trying to build and how my backend code will interact with the frontend layer. This approach allows developers to design their implementation before they write it.
From there, I start working on unit/component testing or integration testing, depending on the work itself. If the architectural design is clear before I dig into the code base, I start writing integration tests. These will also fail for a while as well. While they may not be complete at the moment I write them, they still serve the function of helping me think about what I am trying to build and what my initial design is.
When I move onto the unit/component test, I finally start red-green refactoring in the unit test layer, leaving UI and integration layers in ‘red’ stage and completing my unit tests to the refactoring stage. Then I move back to the integration tests and make the text green, then refactor. Afterward, the same step applies to the UI testing
As you can see, I am applying the TDD principles throughout all the layers of testing. The principle is the same, the only difference is the scale.
BDD: behavior-driven development
User Journey Story and Given, When and Then
Anytime there is a new feature requests, people from the product side of the business write story-level tasks for engineers, including user story and user acceptance criteria. This way, you an engineer can understand the value to the business and think from the user’s perspective about the functionality that they will implement. By seeing user stories, engineers can also better understand the scope of the work.
User-acceptance testing (UI-driven testing) builds on this user acceptance criteria and user story. UI-driven testing usually uses tools like Selenium or Cucumber which help test against the user’s journey on a site that’s up and running.
The user journey story represents a user’s behavior. Using the business requirements provided, the developer can think about scenarios of how a user will use this new functionality. And those scenarios can be used to write the tests. This is called behavior-driven development (BDD).
BDD is a widely-used method in UI-driven testing. It is written in a structure known as “Given, When, and Then.”
Given - the state of the system that will receive the behavior/action
When - the behavior/action that happens and causes the result in the end
Then - the result caused by the behavior in the state
It’s a good idea to think about the user journey and user’s behavior first, so that when you implement your feature, it is with consideration of how the user will interact with it.
For example:
Scenario: User signs up to the site.
Given the user visited the site
When the user clicked the signup button
Then ensure the user can access the signup page
Here are some simple test code examples with Cypress (for an easy integration with this tool, explore the Cypress orb):
describe('User can signup to the test-example site', function() {
it('clicking "signup" navigate to a signup url', function() {
// Given
cy.visit('https://test-example.com/')
// When
cy.contains('signup').click()
//Then
cy.url().should('include', '/signup')
})
})
Using BDD in UI layer testing make sense since it involves the part of the application that the user will interact with. Other layers of testing won’t be as well-suited to using BDD. While UI layer testing with BDD is invaluable to the process of building quality software, it’s very expensive and inefficient (see the testing pyramid in part I of this blog series).
As we mentioned in the previous post, utilizing different layers and kinds of testing means that when something goes wrong, it will be way faster to pinpoint exactly what is failing and be able to fix it more quickly. This reduces debugging time and enables you to detect low-level problems much more cheaply and quickly.
Conclusion: happy path and edge cases
When you write tests, it’s easy to think about what will happen when everything goes well. It can be a challenge for engineers to think about edge cases. That is expected.
When we think about UI layer testing, we are assuming that (almost) the entire site is already up and running and your tests are running against them. Now, it’s hard to imagine every single pathway that a user may take, and it would be very expensive to test every single pathway that could possibly occur. Therefore, it’s a good practice to focus on the happy path and major failure path: these will cover both the main behaviors and the worst-case scenario. Often the edge case bugs are discovered from QA exploratory testing in a QA-like environment. In this process, QA will analyze the business risk and communicate the edge case scenario to the engineers to ensure that they fix the bugs before the code goes to production, and write new tests to cover the edge case scenarios.
When engineers understand the system and circumstances better, it becomes easier to think about edge cases. This results in the tests in the edge cases being covered better by the lower layer of the test pyramid - which is always preferable, as they’re more efficient. That said, maintaining tests are also part of an engineer’s job. When your code evolves, your tests need to be changed as well. Having meaningful and behavior-driven tests is more important than the number of tests that you have. The purpose of testing, after all, is to deliver quality software to production.
Making your code base more testable is a worthwhile investment, and it will help you scale your business and software in the long term. This fundamental work will allow you to optimize your software’s path to production, giving you more confidence every time you deploy.