Writing Tests

Descriptive testing

Tests aren’t only a great way of ensuring your code behaves correctly, they’re also a fantastic form of documentation. Therefore, a test framework should make describing your tests in a clear and concise manner as simple as possible.

Ward lets you describe your tests using strings, allowing you to be as descriptive as you’d like:

from ward import test


@test("simple addition")
def _():
    assert 1 + 2 == 3

The description of a test is a format string, and may refer to any of the parameters (variables or fixtures) present in the test signature. This makes it easy to keep your test data and test descriptions in sync:

@fixture
def three():
    yield 3


@test("{a} + {b} == {result}")
def _(a=1, b=2, result=three):
    assert a + b == result

During the test run, Ward will print the test description to the console.

Tests will only be collected from modules with names that start with “test_” or end with “_test”.

Tagging tests

You can tag tests using the tags keyword argument of the @test decorator:

@test("simple addition", tags=["unit", "regression"])
def _():
    assert 1 + 2 == 3

Tags provide a powerful means of grouping tests and associating queryable metadata with them.

When running your tests, you can filter which ones you want to run using tag expressions.

Here are some ways you could use tags:

  • Linking a test to a ticket from an issue tracker: “BUG-123”, “PULL-456”, etc.

  • Describe what type of test it is: “small”, “medium”, “big”, “unit”, “integration”, etc.

  • Specify which endpoint your test calls: “/users”, “/tweets”, etc.

  • Specify which platform a test targets: “windows”, “unix”, “ios”, “android”

With your tests tagged you can now run only the tests you care about. To ask Ward to run only integration tests which target any mobile platform, you might invoke it like so:

ward --tags "integration and (ios or android)"

For a deeper look into tag expressions, see the running tests page.

Using assert statements

Ward lets you use plain assert statements when writing your tests, but gives you considerably more information should the assertion fail than a typical assert statement. It does this by modifying the abstract syntax tree (AST) of any collected tests. Occurrences of the assert statement are replaced with a function call, depending on which comparison operator was used.

Currently, Ward only rewrites assert statements that appear directly in the body of your tests. If you use helper methods that contain assert statements and would like detailed output, you can use the helper assert_{op} methods from ward.expect.

Parameterised testing

A parameterised test is where you define a single test that runs multiple times, with different arguments being injected on each run.

The simplest way to parameterise tests in Ward is to write your test inside a loop. In each iteration of the loop, you can pass different values into the test:

for lhs, rhs, res in [
    (1, 1, 2),
    (2, 3, 5),
]:

    @test("simple addition")
    def _(left=lhs, right=rhs, result=res):
        assert left + right == result

You can also make a reference to a fixture and Ward will resolve and inject it:

@fixture
def five():
    yield 5


for lhs, rhs, res in [
    (1, 1, 2),
    (2, 3, five),
]:

    @test("simple addition")
    def _(left=lhs, right=rhs, result=res):
        assert left + right == result

Ward also supports parameterised testing by allowing multiple fixtures or values to be bound as a keyword argument using the each function:

from ward import each, fixture, test


@fixture
def six():
    return 6


@test("an example of parameterisation")
def _(
    a=each(1, 2, 3),
    b=each(2, 4, six),
):
    assert a * 2 == b

Although the example above is written as a single test, Ward will generate and run 3 distinct tests from it at run-time: one for each item passed into each.

The variables a and b take the values a=1 and b=2 in the first test, a=2 and b=4 in the second test, and the third test will be passed the values a=3 and b=6.

If any of the items inside each is a fixture, that fixture will be resolved and injected. Each of the test runs are considered unique tests from a fixture scoping perspective.

Warning

All occurrences of each in a test signature must contain the same number of arguments.

Using each in a test signature doesn’t stop you from injecting other fixtures as normal:

from ward import each, fixture, test


@fixture
def book_api():
    return BookApi()


@test("BookApi.get_book returns the correct book given an ISBN")
def _(
    api=book_api,
    isbn=each("0765326353", "0765326361", "076532637X"),
    name=each("The Way of Kings", "Words of Radiance", "Oathbringer"),
):
    book: Book = api.get_book(isbn)
    assert book.name == name

Ward will expand the parameterised test above into 3 distinct tests.

In other words, the single parameterised test above is functionally equivalent to the 3 tests shown below:

@test("[1/3] BookApi.get_book returns the correct book given an ISBN")
def _(
    api=book_api,
    isbn="0765326353",
    name="The Way of Kings",
):
    book: Book = api.get_book(isbn)
    assert book.name == name


@test("[2/3] BookApi.get_book returns the correct book given an ISBN")
def _(
    api=book_api,
    isbn="0765326361",
    name="Words of Radiance",
):
    book: Book = api.get_book(isbn)
    assert book.name == name


@test("[3/3] BookApi.get_book returns the correct book given an ISBN")
def _(
    api=book_api,
    isbn="076532637X",
    name="Oathbringer",
):
    book: Book = api.get_book(isbn)
    assert book.name == name

If you’d like to use the same book_api instance across each of the three generated tests, you’d have to increase its scope to module or global.

Currently, each can only be used in the signature of tests.

Checking for exceptions

The test below will pass, because a ZeroDivisionError is raised. If a ZeroDivisionError wasn’t raised, the test would fail:

from ward import raises, test


@test("a ZeroDivision error is raised when we divide by 0")
def _():
    with raises(ZeroDivisionError):
        1 / 0

If you need to access the exception object that your code raised, you can use with raises(<exc_type>) as <exc_object>:

def my_func():
    raise Exception("oh no!")


@test("the message is 'oh no!'")
def _():
    with raises(Exception) as ex:
        my_func()
    assert str(ex.raised) == "oh no!"

Note that ex is only populated after the context manager exits, so be careful with your indentation.

Testing async code

You can declare any test or fixture as async in order to test asynchronous code:

@fixture
async def post():
    return await create_post("hello world")


@test("a newly created post has no children")
async def _(p=post):
    children = await p.children
    assert children == []


@test("a newly created post has an id > 0")
def _(p=post):
    assert p.id > 0

Skipping a test

Use the @skip decorator to tell Ward not to execute a test:

from ward import skip


@skip
@test("I will be skipped!")
def _():
    ...

You can pass a reason to the skip decorator, and it will be printed next to the test name/description during the run:

@skip("not implemented yet")
@test("everything is okay")
def _():
    ...

To conditionally skip a test in some circumstances (for example, on specific OS’s), you can supply a when predicate to the @skip decorator. This can be either a boolean or a Callable, and will be evaluated just before the test is scheduled to be executed. If it evaluates to True, the test will be skipped. Otherwise the test will run as normal.

Here’s an example of a test that is skipped on Windows:

import platform


@skip("Skipped on Windows", when=platform.system() == "Windows")
@test("_build_package_data constructs package name '{pkg}' from '{path}'")
def _(
    pkg=each("", "foo", "foo.bar"),
    path=each("foo.py", "foo/bar.py", "foo/bar/baz.py"),
):
    m = ModuleType(name="")
    m.__file__ = path
    assert _build_package_data(m) == pkg
Output of a conditionally skipped, parameterised test.

Expecting a test to fail

You can mark a test that you expect to fail with the @xfail decorator.

from ward import xfail


@xfail("its really not okay")
@test("everything is okay")
def _():
    ...

If a test decorated with @xfail does indeed fail as we expected, it is shown in the results as an XFAIL.

You can conditionally apply @xfail using the same approach as we described for @skip above.

For example, we expect the test below to fail, but only when it’s run in a Python 3.6 environment.

from ward import xfail


@xfail("expected fail on Python 3.6", when=platform.python_version().startswith("3.6"))
@test("everything is okay")
def _():
    ...

If a test marked with this decorator passes unexpectedly, it is known as an XPASS (an unexpected pass).

If an XPASS occurs during a run, the run will be considered a failure.