Unit Testing Best Practises

Characteristics of a Good Unit Test

Self-checking - a test should automatically determine if it has passed or failed without user interaction

Repeatable - a test should return consistent results provided all input values remain the same between test runs

Fast - tests should not require much time to execute

Timely - unit tests should not require a disproportionate amount of time to write compared to the code that they are testing. If tests are taking a long time to write compared to the code being tested, consider refactoring the code to be more testable.

Isolated - unit tests should not depend on outside factors and be run in isolation

Code Coverage

A high code coverage cannot determine the quality of the code, but is simply a metric of the amount of code being tested. Not all methods need to be tested (eg: accessor methods), but it is important to maintain as high of a code coverage metric as possible on TESTABLE code to help maintain the stability of the software as it grows and is adapted.

Best Practises

Try to avoid introducing dependencies on infrastructure when writing unit tests. These make tests slow and possibly unreliable and should be reserved to integration tests.

Naming Conventions

The name of a test should consist of 2-3 parts:

  1. the name of the method being tested

  2. the condition under which the test occurs (optional)

  3. the expected behaviour under the condition invoked

Naming standards are important as they verbosely express the intent of a test.

Example of proper naming conventions:

public void createBasicEvent_returnsNewEvent() 
public void createBasicEvent_invalidTitle_shouldFailValidation()

Example of improper naming convention:

public void basicTest()
public void createEvent()

Arrangement Within a Test

Arrange, Act, Assert is a common pattern when unit testing. As the name implies, it consists of three main actions:

  • Arrange your objects, creating and setting them up as necessary.

  • Act on an object.

  • Assert that something is as expected.

Example of improper test design:

public void add_emptyvalues_shouldReturn0() {
   //arrange
   $calculator = new Calculator();

   //assert
   Assert.equals(0, $calculator->add('',''));
}

Example of proper test design:

public void add_emptyvalues_shouldReturn0() {
   //arrange
   $calculator = new Calculator();

   //act
   $result = $calculator->add('','');

   //assert
   Assert.equals(0, $result);
}

Readability is one of the more important aspects when writing good unit tests. Clearly separating what is being tested from the arrange and assert steps allows for multiple assertions to be performed on a single result.

Separating each of these actions within the test clearly highlight the dependencies required to call your code, how your code is being called, and what you are trying to assert. While it may be possible to combine some steps and reduce the size of your test, the primary goal is to make the test as readable as possible.

Avoid Literal Strings

Naming of variables in unit tests is just as important as naming conventions in code (if not more so). Unit tests should never contain literal strings. This removes the need for the reader of the test to inspect the code to determine what makes the value important.

example of string literal (no indication of reasoning):

public void createEvent_shouldSetColor() {
   //arrange
   $event = new Event();
   //act
   $event->bgColor = '#BD0D1B'; //there is no obvious reason for this choice
   //assert
   ...
}

For Codeception an example of proper reason indication would be:

    /**
     * @param  FunctionalTester  $I
     *
     * @return void
     * @throws Exception
     */
    public function testBackgroundColorBlack(FunctionalTester $I) {
        $this->changeBGColor($I, self::BACKGROUND_BLACK);
        $this->createEvent($I);
        $I->amOnPage('/admin/events');
        $I->clickTextOf(self::REMOVE_FILTERS);
        $I->waitForText(self::TITLE);
        $I->clickTextOf(self::CALENDAR_VIEW);
        $I->waitForText(self::TITLE);

        $I->seeInSource('background-color:' . self::BACKGROUND_BLACK . ';color:white;">' . self::TITLE);
    }

Avoid Logic in Tests

When writing your unit tests avoid manual string concatenation and logical conditions such as if, while, for, switch, etc.

This reduces the chance to introduce a bug inside of your tests. It also focuses more on the result rather than the implementation details.

public void add_excessUsers_throwsException() {
   //arrange
   $service = new UserService()

   //act
   for($index = 0; $index < 100; $index++) { //iterative logic should be avoided
      try {
          $service->addUser(new User());
      }catch(\ExcessUsersException $) {

      }
   }
   //assert
   ...
}

When you introduce logic into your test suite, the chance of introducing a bug into it increases dramatically. The last place that you want to find a bug is within your test suite. You should have a high level of confidence that your tests work, otherwise, you will not trust them. Tests that you do not trust, do not provide any value. When a test fails, you want to have a sense that something is actually wrong with your code and that it cannot be ignored.

Tip: If logic in your test is unavoidable consider splitting the test up into 2 or more separate tests

Avoid Multiple 'Acts' Inside of a Test

Each test should perform only one 'act' in a test. That means a separate test should be written for each Act.

Why?

  • If a test fails it may not be clear which act is failing

  • It ensures each test is focused on a single condition

  • Ensures the name of the test matches the Act

Example of test with multiple acts:

public void add_emptyvalues_shouldReturn0() {
   //arrange
   $calculator = new Calculator();

   //act
   $result1 = $calculator->add('','');
   $result2 = $calculator->add('0','');
   $result3 = $calculator->add('0','0');

   //assert
   Assert.equals(0, $result1);
   Assert.equals(0, $result2);
   Assert.equals(0, $result3);
}

Example of tests properly segregated acts:

public void add_emptyvalues_shouldReturn0() {
   //arrange
   $calculator = new Calculator();

   //act
   $result = $calculator->add('','');

   //assert
   Assert.equals(0, $result);
}

public void add_single0_shouldReturn0() {
   //arrange
   $calculator = new Calculator();

   //act
   $result = $calculator->add('0','');

   //assert
   Assert.equals(0, $result);
}

public void add_double0s_shouldReturn0() {
   //arrange
   $calculator = new Calculator();

   //act
   $result = $calculator->add('0','0');

   //assert
   Assert.equals(0, $result);
}

Avoid Validating Private Methods

There should be no need to test a private method. Private methods are an implementation detail. At some point in the code there will be a public method that calls the private method as part of its process. What matters is the end result from the public facing method. If you feel the desire to test a private method you are leaning towards debugging, which is not the intent of a unit test.

Code Coverage

All aspects of a method should be properly covered with unit tests. That means every if/else, try/catch, switch/case, and return should have a corresponding test provided. This is part of the process of 'proving' the code works as expected.

example:

class MixedSalad {
    const DETERMINED_VEGETABLE_TOMATO = "sorry kid, it's a tomato for dinner";
    const DETERMINED_VEGETABLE_CARROT = "carrots are said to be good for the eyes";
    const DETERMINED_VEGETABLE_GRAPE = "grapes are not a vegetable";
    const DETERMINED_VEGETABLE_UNEXPECTED = "I've no idea what you sent me";

    const ALLOWED_VEGETABLE_TOMATO = 'tomato';
    const ALLOWED_VEGETABLE_CARROT = 'carrot';
    const ALLOWED_VEGETABLE_GRAPE = 'grape';

    public function determineVegetable(string $something) : string {
        switch ($something) {
           case self::ALLOWED_VEGETABLE_TOMATO:
              return self::DETERMINED_VEGETABLE_TOMATO;
           case self::ALLOWED_VEGETABLE_CARROT:
              return self::DETERMINED_VEGETABLE_CARROT;
           case self::ALLOWED_VEGETABLE_GRAPE:
              return self::DETERMINED_VEGETABLE_GRAPE;
           default:
              return self::DETERMINED_VEGETABLE_UNEXPECTED;
        }
    }
}
----- tests ----

public function testTomato_shouldReturnFound() {
    //arrange
   $class = new MixedSalad();

   //act
   $result = $class->determineVegetable(MixedSalad::ALLOWED_VEGETABLE_TOMATO);
  
   //assert
   $this->assertEquals(MixedSalad::DETERMINED_VEGETABLE_TOMATO);
}

public function testCarrot_shouldReturnFound() {
    //arrange
   $class = new MixedSalad();

   //act
   $result = $class->determineVegetable(MixedSalad::ALLOWED_VEGETABLE_CARROT);
  
   //assert
   $this->assertEquals(MixedSalad::DETERMINED_VEGETABLE_CARROT);
}

public function testGrape_shouldReturnFound() {
    //arrange
   $class = new MixedSalad();

   //act
   $result = $class->determineVegetable(MixedSalad::ALLOWED_VEGETABLE_GRAPE);
  
   //assert
   $this->assertEquals(MixedSalad::DETERMINED_VEGETABLE_GRAPE);
}

public function testUnexpected_shouldReturnNotFound() {
    //arrange
   $class = new MixedSalad();

   //act
   $result = $class->determineVegetable('');
  
   //assert
   $this->assertEquals(MixedSalad::DETERMINED_VEGETABLE_UNEXPECTED);
}

Every case in the above example has a test to ensure maximum coverage.

If we neglected to write a test for 'grape' the code coverage map would indicate the omission.

File Locations

For Unit and Integration tests (API repository) it is advisable to follow the namespace path of the file being tested.

/tests
  /Integration
    /Courses
      /Http
        /Controllers
          CurriculumCourseControllerTest.php

File Naming Conventions

File names should be verbose in identifying which class they are testing.

For Unit tests it is recommended that files match the name of the class plus the word 'Test'.

example:

CurriculumCourseControllerTest will test the CurriculumCourseController.

LotteriesServiceTest will test the LotteriesService.

Establishing a Baseline

It is recommended to prove the method works as intended under normal operating conditions. The first tests on the page is often the 'baseline' test to show that a class's methods work as intended, followed by the 'challenge' tests that are aimed at proving the code functions as intended under abnormal conditions.

Data Providers

Data providers are used when the same test is executed with different data inputs and expected results. This is useful when you are testing a method that has multiple if/else or switch/case statements.

public function determineVegetable(string $something) : string {
        switch ($something) {
           case self::ALLOWED_VEGETABLE_TOMATO:
              return self::DETERMINED_VEGETABLE_TOMATO;
           case self::ALLOWED_VEGETABLE_CARROT:
              return self::DETERMINED_VEGETABLE_CARROT;
           case self::ALLOWED_VEGETABLE_GRAPE:
              return self::DETERMINED_VEGETABLE_GRAPE;
           default:
              return self::DETERMINED_VEGETABLE_UNEXPECTED;
        }
    }

Instead of duplicating the test method and changing the inputs, a Data Provider can be used:

private $classToTest;

public void setUp()
{
   //arrange
   $this->classToTest = new MixedSalad();
}

/**
* @dataProvider provideVegetablesData
*/
public function testDetermineVegetable(string $expected, string $testData): void
{
   //act
   $result = $this->classToTest->determineVegetable($testData);
  
   //assert
   $this->assertEquals($expected, $result);
}
public function provideVegetablesData()
{
    return [
        'vegetable is tomato' => [
            VegetablesService::ALLOWED_VEGETABLE_TOMATO,
            VegetablesService::DETERMINED_VEGETABLE_CARROT,
        ],
        'vegetable is lettuce' => [
            VegetablesService::ALLOWED_VEGETABLE_CARROT,
            VegetablesService::DETERMINED_VEGETABLE_CARROT,
        ],
        'vegetable is grape' => [
            VegetablesService::ALLOWED_VEGETABLE_GRAPE,
            VegetablesService::DETERMINED_VEGETABLE_CARROT,
        ],
        'vegetable is undetermined' => [
            'unkown item',
            VegetablesService::DETERMINED_VEGETABLE_UNEXPECTED,
        ],
    ];
}

More information on Data Providers

TL;DR

Readability is key

Avoid literal strings in favour of const values

A test method name should consist of 2-3 parts

A test method should do 3 things: Arrange, Act, Assert

Only 1 Act per test

Avoid 'logic' in tests (loops, etc)

Every logic branch if/else, switch/case, try/catch should have a corresponding test

File paths should reflect the path of the class being tested

Filenames should reflect the name of the class

Use a Data Provider when repeating tests on a function with varied arguments passed

Last updated