Workflow for writing a Python test

Normally we write functional tests in Python.

  • We use pytest for our Python test infrastructure.
  • Python tests should be added to tests/python/test_xxx.py.

For example, you’ve just added a utility function ti.log10. Now you want to write a test, to test if it functions properly.

Adding a new test case

Look into tests/python, see if there’s already a file suit for your test. If not, feel free to create a new file for it :) So in this case let’s create a new file tests/python/test_logarithm.py for simplicity.

Add a function, the function name must be started with test_ so that pytest could find it. e.g:

import taichi as ti

def test_log10():
    pass

Add some simple code make use of our ti.log10 to make sure it works well. Hint: You may pass/return values to/from Taichi-scope using 0-D tensors, i.e. r[None].

import taichi as ti

def test_log10():
    ti.init(arch=ti.cpu)

    r = ti.var(ti.f32, ())

    @ti.kernel
    def foo():
        r[None] = ti.log10(r[None])

    r[None] = 100
    foo()
    assert r[None] == 2

Execute ti test logarithm, and the functions starting with test_ in tests/python/test_logarithm.py will be executed.

Testing against multiple backends

The above method is not good enough, for example, ti.init(arch=ti.cpu), means that it will only test on the CPU backend. So do we have to write many tests test_log10_cpu, test_log10_cuda, … with only the first line different? No worries, we provide a useful decorator @ti.test:

import taichi as ti

# will test against both CPU and CUDA backends
@ti.test(ti.cpu, ti.cuda)
def test_log10():
    r = ti.var(ti.f32, ())

    @ti.kernel
    def foo():
        r[None] = ti.log10(r[None])

    r[None] = 100
    foo()
    assert r[None] == 2

And you may test against all backends by simply not specifying the argument:

import taichi as ti

# will test against all backends available on your end
@ti.test()
def test_log10():
    r = ti.var(ti.f32, ())

    @ti.kernel
    def foo():
        r[None] = ti.log10(r[None])

    r[None] = 100
    foo()
    assert r[None] == 2

Cool! Right? But that’s still not good enough.

Using ti.approx for comparison with tolerance

Sometimes the math percison could be poor on some backends like OpenGL, e.g. ti.log10(100) may return 2.001 or 1.999 in this case.

To cope with this behavior, we provide ti.approx which can tolerate such errors on different backends, for example 2.001 == ti.approx(2) will return True on the OpenGL backend.

import taichi as ti

# will test against all backends available on your end
@ti.test()
def test_log10():
    r = ti.var(ti.f32, ())

    @ti.kernel
    def foo():
        r[None] = ti.log10(r[None])

    r[None] = 100
    foo()
    assert r[None] == ti.approx(2)

警告

Simply using pytest.approx won’t work well here, since it’s tolerance won’t vary among different Taichi backends. It’ll be likely to fail on the OpenGL backend.

ti.approx also do treatments on boolean types, e.g.: 2 == ti.approx(True).

Great on improving stability! But the test is still not good enough, yet.

Parametrize test inputs

For example, r[None] = 100, means that it will only test the case of ti.log10(100). What if ti.log10(10)? ti.log10(1)?

We may test against different input values using the @pytest.mark.parametrize decorator:

import taichi as ti
import pytest
import math

@pytest.mark.parametrize('x', [1, 10, 100])
@ti.test()
def test_log10(x):
    r = ti.var(ti.f32, ())

    @ti.kernel
    def foo():
        r[None] = ti.log10(r[None])

    r[None] = x
    foo()
    assert r[None] == math.log10(x)

Use a comma-separated list for multiple input values:

import taichi as ti
import pytest
import math

@pytest.mark.parametrize('x,y', [(1, 2), (1, 3), (2, 1)])
@ti.test()
def test_atan2(x, y):
    r = ti.var(ti.f32, ())
    s = ti.var(ti.f32, ())

    @ti.kernel
    def foo():
        r[None] = ti.atan2(r[None])

    r[None] = x
    s[None] = y
    foo()
    assert r[None] == math.atan2(x, y)

Use two separate parametrize to test all combinations of input arguments:

import taichi as ti
import pytest
import math

@pytest.mark.parametrize('x', [1, 2])
@pytest.mark.parametrize('y', [1, 2])
# same as:  .parametrize('x,y', [(1, 1), (1, 2), (2, 1), (2, 2)])
@ti.test()
def test_atan2(x, y):
    r = ti.var(ti.f32, ())
    s = ti.var(ti.f32, ())

    @ti.kernel
    def foo():
        r[None] = ti.atan2(r[None])

    r[None] = x
    s[None] = y
    foo()
    assert r[None] == math.atan2(x, y)

Specifying ti.init configurations

You may specify keyword arguments to ti.init() in ti.test(), e.g.:

@ti.test(ti.cpu, debug=True, log_level=ti.TRACE)
def test_debugging_utils():
    # ... (some tests have to be done in debug mode)

is the same as:

def test_debugging_utils():
    ti.init(arch=ti.cpu, debug=True, log_level=ti.TRACE)
    # ... (some tests have to be done in debug mode)

Exclude some backends from test

Sometimes some backends are not capable of specific tests, we have to exclude them from test:

# Run this test on all backends except for OpenGL
@ti.test(excludes=[ti.opengl])
def test_sparse_tensor():
    # ... (some tests that requires sparse feature which is not supported by OpenGL)

You may also use the extensions keyword to exclude backends without specific feature:

# Run this test on all backends except for OpenGL
@ti.test(extensions=[ti.extension.sparse])
def test_sparse_tensor():
    # ... (some tests that requires sparse feature which is not supported by OpenGL)