Unit testing is something most developers run into at some point in their careers. For some developers, it is there from day one, for others it comes and goes. In my experience, it seems like most developers dislike the unit testing part of writing clean and efficient code until they realize they are writing their tests wrong.
Writing tests for days
Most developers will focus on writing implementation tests for every method they create. This is tedious, requires constant updates, and is inefficient. When you are writing three or four tests for each method it is easy to dread writing those tests, but most developers are never taught any other way to write or think about those tests. This same implementation-based testing can lead to bugs and assumptions that your code will perform correctly with any given state. Don't get me wrong implementation tests have their place, but they should be very technical and focus on validating the specific functionality of critical methods in your code. Implementation tests are rigid and follow the instruction-based thinking of programmers, testing that every step along the way behaves correctly, but not necessarily testing that given a specific state the code as an entire body behaves correctly.
What is Behavior-Driven Testing
Behavior-Driven Testing (BDT) is the opposite of implementation testing, it looks at the code from the outside in describing the behaviors of the application. Designing these tests can even include Stakeholders and Project Managers since they help create a shared language and understanding of the functionality. You can think of BDT as a method to describe and test what software should do by defining features, and scenarios, and enforcing clean and modular code that is easy to test. Additionally, BDT generally creates better code coverage (defines tests for more branches and paths), aids in creating clear documentation, and improves code quality by allowing devs to focus more on writing code and not testing it or refactoring tests. Often behavior-driven tests do not require modifications when the code is changed.
BDT vs Implementation
Comparing the two BDT follows more of the following:
I order a package, there are no holidays and no weather events.
Given my order is successfully completed
It should arrive on time and without damage.
You care about the outcome of the total process, not validating every step of the process. Implementation testing is similar to:
I place my order
It is pulled from the shelf
It is put in a box
A Shipping label is created
The box is put on a truck
The truck travels 30 miles to the shipping Partner
...
You cover all these with BDT but the key difference in an implementation test is you are covering those methods in isolation from the rest of the application flow. This means you have to assume the state and the responses from other methods. You may write 10 tests for one of these methods, but what is the possibility you miss a change of state that happens before this method is ever reached? If you are testing the departure time of the truck and you miss a delay pulling from the shelf how does that change your test? Implementation testing forces you to write specific tests for each method for each possible branch/path your code takes.
Thinking about it without the programmer's glasses
An example of BDT and Behavior-Driven Design would be making a board game with your friends or family. You have the main idea of the game but need to ensure that when given a specific state when an action occurs that the result is predictable. So you may start defining behaviors If a player has won 3 battles when they complete board section A then they should be awarded 100 XP
. This clearly defines a testable behavior that should be consistent with the given state.
Thinking about it as a programmer
In terms of programming BDT can be broken down into a fairly simple sentence Given a specific state when an action or series of actions are completed the outcome should be predictable and repeatable
. What exactly does that mean and how can we define behaviors? Feature files and scenarios are tools used in BDT to define behaviors in a common language (usually Gherkin). Let's use authenticating users as an example.
FEATURE: User Authentication
As a user I want to be able to log in and out of my account
SCENARIO: Successful login
GIVEN I am on the login page and supply the correct credentials
WHEN the login form is submitted
THEN I should be logged in
AND a JWT should be returned
AND I should be redirected to my home page
SCENARIO: Failed login
GIVEN I am on the login page and use incorrect credentials
WHEN the login form is submitted
THEN I should not be logged in
AND I should be given an error message
AND I should not be redirected
AND I should not be allowed to manually access my home page
SCENARIO: Logged out
GIVEN I am logged in successfully
WHEN I hit the logout button in the main navigation
THEN I should be logged out
AND the JWT destroyed
AND I should be redirected to the home page
AND I should not be allowed to manually access my home page
You can see in the example above the different behaviors of User Authentication are described from the perspective of a user. This allows us to test the entirety of the authentication functionality from three behaviors. Another way to think about this is testing that given a beginning state, the outcome will always be the same. In the example above given correct user credentials the outcome should always be as defined in the THEN
section.
Testing Behaviors
When it comes to testing behaviors you should mock as little as possible to ensure all the code from start to finish is correctly functioning, this helps avoid the need for implementation tests later on. Some things to mock would be database calls or HTTP requests. If you are using Adapters for these areas or areas like third-party libraries mocking them becomes even easier. Overall, the less you have mocked the more you can trust your behavior tests are correctly representing the outcome of the executed code.
You can use tools like Behat which are designed specifically for BDT and make use of Gherkin feature files to help automate test building. In most cases, you are probably using PHPUnit so we can start there. You have already done some of the work in defining your scenarios, these translate to test methods.
<?php
use PHPUnit\Framework\TestCase;
class UserAuthenticationTest extends TestCase {
public function testThatGivenCorrectCredentialsTheUserIsLoggedIn() {
//
}
public function thestThatGivenIncorrectCredentialsTheUserIsNotLoggedIn() {
//
}
public function testThatTheLoggedInUserCanLogOut() {
//
}
}
These three tests will cover the possible behaviors of your authentication workflow. Assuming that you have a controller that handles the login we could write something like the following.
<?php
use PHPUnit\Framework\TestCase;
use App\Controllers\Login_Controller;
use App\Core\Database;
class UserAuthenticationTest extends TestCase {
public function testThatGivenCorrectCredentialsTheUserIsLoggedIn() {
$Login_Controller = new Login_Controller;
$Mock_DB = $this->getMockBuilder(Database::class)->onlyMethods(['fetchRow'])->getMock();
$Mock_DB->expects($this->once())->method('fetchRow')->willReturn(['username' => 'user', 'password' => 'somehashedpassword']);
$Login_Controller->Database = $Mock_DB;
$result = $Login_Controller->logUserIn('user', 'password');
$this->assertArrayHasKey('jwt', $result);
$this->assertSame('ok', $result['success']);
}
// ...
}
In the example above we are just passing in a mocked database class and letting the rest of the code run as it should. This allows us to test that all of the code in that behavior is functioning correctly to produce the desired outcome. The database class would likely be implementation tested since it is a critical component to the application so we can feel comfortable mocking that. Now once you create tests for the remaining two scenarios you will most likely cover all, or close to all, of the code involved in your user authentication process.
You may need to add more scenarios for disabled or suspended accounts etc but this is just a gist to show the strength of testing behaviors. The best part is if any of the code changes that are used for the given scenario you may not have to update the tests at all. This is because the behavior is tested as a whole with a starting state that produces a repeatable result. If the result has changed there is likely an error or a change in the process, like renaming the jwt
key. Compared to an implementation-based approach if you changed any of the code the authentication scenario depends on you would need to update each test for the change.
Conclusion
Behavior-Driven Testing is another tool in your developer belt that can help you write fewer tests, spend less time refactoring tests, have greater confidence in the reliability of your tests, and help define a shared definition of the features of your application.
Top comments (0)