Writing Tests

Writing tests for Nette Tester is unique in that each test is a PHP script that can be run standalone.. This has great potential. As you write the test, you can simply run it to see if it works properly. If not, you can easily step through in the IDE and look for a bug.

You can even open the test in a browser. But above all – by running it, you will perform the test. You will immediately find out if it passed or failed.

In the introductory chapter, we showed a really trivial test of using PHP array. Now we will create our own class, which we will test, although it will also be simple.

Let's start with a typical directory layout for a library or project. It is important to separate the tests from the rest of the code, for example due to deployment, because we do not want to upload tests to server. The structure may be as follows:

├── src/           # code that we will test
│   ├── Rectangle.php
│   └── ...
├── tests/         # tests
│   ├── bootstrap.php
│   ├── RectangleTest.php
│   └── ...
├── vendor/
└── composer.json

And now we will create individual files. We will start with the tested class, which we will place in the file src/Rectangle.php

<?php
class Rectangle
{
	private float $width;
	private float $height;

	public function __construct(float $width, float $height)
	{
		if ($width < 0 || $height < 0) {
			throw new InvalidArgumentException('The dimension must not be negative.');
		}
		$this->width = $width;
		$this->height = $height;
	}

	public function getArea(): float
	{
		return $this->width * $this->height;
	}

	public function isSquare(): bool
	{
		return $this->width === $this->height;
	}
}

And we'll create a test for it. The name of the test file should match mask *Test.php or *.phpt, we will choose the variant RectangleTest.php:

<?php
use Tester\Assert;

require __DIR__ . '/bootstrap.php';

// general oblong
$rect = new Rectangle(10, 20);
Assert::same(200.0, $rect->getArea());  # we will verify the expected results
Assert::false($rect->isSquare());

As you can see, assertion methods such as Assert::same() are used to assert that an actual value matches an expected value.

The last step is to create file bootstrap.php. It contains a common code for all tests. For example classes autoloading, environment configuration, temporary directory creation, helpers and similar. Every test loads the bootstrap and pays attention to testing only. The bootstrap can look like:

<?php
require __DIR__ . '/vendor/autoload.php';  # load Composer autoloader

Tester\Environment::setup();               # initialization of Nette Tester

// and other configurations (just an example, in our case they are not needed)
date_default_timezone_set('Europe/Prague');
define('TmpDir', '/tmp/app-tests');

This bootstrap assumes that the Composer autoloader will be able to load the class Rectangle.php as well. This can be achieved, for example, by setting the autoload section in composer.json, etc.

We can now run the test from the command line like any other standalone PHP script. The first run will reveal any syntax errors, and if you didn't make a typo, you will see:

$ php RectangleTest.php

OK

If we change in the test the statement to false Assert::same(123, $rect->getArea());, this will happen:

$ php RectangleTest.php

Failed: 200.0 should be 123

in RectangleTest.php(5) Assert::same(123, $rect->getArea());

FAILURE

When writing tests, it is good to catch all extreme situations. For example, if the input is zero, a negative number, in other cases an empty string, null, etc. In fact, it forces you to think and decide how the code should behave in such situations. The tests then fix the behavior.

In our case, a negative value should throw an exception, which we verify with Assert::exception():

// the width must not be negative number
Assert::exception(
	fn() => new Rectangle(-1, 20),
	InvalidArgumentException::class,
	'The dimension must not be negative.',
);

And we add a similar test for height. Finally, we test that isSquare() returns true if both dimensions are the same. Try to write such tests as an exercise.

Well-Arranged Tests

The size of the test file can increase and quickly become cluttered. Therefore, it is practical to group individual tested areas into separate functions.

First, we will show a simpler but elegant variant, using the global function test(). The tester doesn't create it automatically, to avoid a collision if you had a function with the same name in your code. It is only created by the setupFunctions() method, which you call in the bootstrap.php file:

Tester\Environment::setup();
Tester\Environment::setupFunctions();

Using this function, we can nicely divide the test file into named units. When executed, labels will be displayed one after the other.

<?php
use Tester\Assert;

require __DIR__ . '/bootstrap.php';

test('general oblong', function () {
	$rect = new Rectangle(10, 20);
	Assert::same(200.0, $rect->getArea());
	Assert::false($rect->isSquare());
});

test('general square', function () {
	$rect = new Rectangle(5, 5);
	Assert::same(25.0, $rect->getArea());
	Assert::true($rect->isSquare());
});

test('dimensions must not be negative', function () {
	Assert::exception(
		fn() => new Rectangle(-1, 20),
        InvalidArgumentException::class,
	);

	Assert::exception(
		fn() => new Rectangle(10, -1),
        InvalidArgumentException::class,
	);
});

If you need to run the code before or after each test, pass it to setUp() or tearDown():

setUp(function () {
	// initialization code to run before each test()
});

The second variant is object. We will create the so-called TestCase, which is a class where individual units are represented by methods whose names begin with test–.

class RectangleTest extends Tester\TestCase
{
	public function testGeneralOblong()
	{
		$rect = new Rectangle(10, 20);
		Assert::same(200.0, $rect->getArea());
		Assert::false($rect->isSquare());
	}

	public function testGeneralSquare()
	{
		$rect = new Rectangle(5, 5);
		Assert::same(25.0, $rect->getArea());
		Assert::true($rect->isSquare());
	}

	/** @throws InvalidArgumentException */
	public function testWidthMustNotBeNegative()
	{
		$rect = new Rectangle(-1, 20);
	}

	/** @throws InvalidArgumentException */
	public function testHeightMustNotBeNegative()
	{
		$rect = new Rectangle(10, -1);
	}
}

// Run test methods
(new RectangleTest)->run();

This time we used an annotation @throw to test for exceptions. See the TestCase ​​chapter for more information.

Helpers Functions

Nette Tester includes several classes and functions that can make testing easier for you, for example, helpers to test the content of an HTML document, to test the functions of working with files, and so on.

You can find a description of them on the page Helpers.

Annotation and Skipping Tests

Test execution can be affected by annotations in the phpDoc comment at the beginning of the file. For example, it might look like this:

/**
 * @phpExtension pdo, pdo_pgsql
 * @phpVersion >= 7.2
 */

The annotations say that the test should only be run with PHP version 7.2 or higher and if the PHP extensions pdo and pdo_pgsql are present. These annotations are controlled by command line test runner, which, if the conditions are not met, skips the test and marks it with the letter s – skipped. However, they have no effect when the test is run manually.

For a description of annotations, see Test Annotations.

The test can also be skipped based on own condition with Environment::skip(). For example, we will skip this test on Windows:

if (defined('PHP_WINDOWS_VERSION_BUILD')) {
	Tester\Environment::skip('Requires UNIX.');
}

Directory Structure

For only slightly larger libraries or projects, we recommend dividing the test directory into subdirectories according to the namespace of the tested class:

└── tests/
	├── NamespaceOne/
	│   ├── MyClass.getUsers.phpt
	│   ├── MyClass.setUsers.phpt
	│   └── ...
	│
	├── NamespaceTwo/
	│   ├── MyClass.creating.phpt
	│   ├── MyClass.dropping.phpt
	│   └── ...
	│
	├── bootstrap.php
	└── ...

You will be able to run tests from a single namespace ie subdirectory:

tester tests/NamespaceOne

Edge Cases

A test that does not call any assertion method is suspicious and will be evaluated as erroneous:

Error: This test forgets to execute an assertion.

If the test without calling assertions is really to be considered valid, call for example Assert::true(true).

It can also be treacherous to use exit() and die() to end the test with an error message. For example, exit('Error in connection') ends the test with a exit code 0, which signals success. Use Assert::fail('Error in connection').