DEV Community

dvnc0
dvnc0

Posted on

Behavior-Driven Testing with PHP and PHPUnit

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.
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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() {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
    }
     // ...
}
Enter fullscreen mode Exit fullscreen mode

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)