Skip to content
This repository has been archived by the owner on Nov 1, 2024. It is now read-only.

Latest commit

 

History

History

unit

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 

Writing unit tests

Unit tests are a great way for developers to get immediate feedback when introducing new features, behavior, fixing bugs, and to prevent future regressions.

There are many resources describing the benefit and usage of what you can do with unit tests. But, for python, there are few resources devoted to best practices when creating a great pytest test suite.

Based on developer experience, we standardized test suite "best practices" for Carta that you can implement just as easily.

Folder structure

Folder structure for tests follow a defined pattern. The rule to follow is that for any given python file the corresponding test file should be easy to find.

Good test folder structure mimics the root directory. For instance, If the python file you are adding behavior to is located at /apps/vehicles/models.py the corresponding tests for the classes in that file are located at /tests/unit/apps/vehicles/models/.

Discuss this guideline

One file per class

Inside of the file /apps/vehicles/models.py there is a Car class. The tests for this class are easily found in the file: /apps/vehicles/models/test_car.py

Discuss this guideline

Files without Classes

Inside of the file /app/vehicles/helpers.py there are only top level function definitions and no classes. The tests for these functions could be found in /app/vehicles/test_helpers.py.

Classes for methods under test

Inside of test_car.py there will be classes which mimic the public interfaces of the object. For instance, if the Car class has the method start() there will be a class in the test file called, class TestStart.

The functions that belong to the TestStart class correspond to the code under test.

# bad
class TestThatTheCarStarts:

# good
# describing Car.start()
# located in car.py
class TestStart:

Discuss this guideline

How to describe your test methods

Be clear about what behavior you are describing. When describing a context, start its description with "when" or "with".

# bad
def test_car_does_not_start_if_no_gas_in_the_tank(self):
    car = Car(fuel=None)

    with pytest.raises(RuntimeError):
        car.start()

# good
def test_when_tank_is_empty_then_car_does_not_start(self):
    car = Car(fuel=None)

    with pytest.raises(RuntimeError):
        car.start()

Discuss this guideline

One assertion per test

The "one expectation" tip is more broadly expressed as "each test should make only one assertion". This helps you on finding possible errors, going directly to the failing test, and to make your code readable.

In isolated unit specs, you want each example to specify one (and only one) behavior. Multiple expectations in the same example is a code smell that you may be specifying multiple behaviors.

# bad
class TestGetCarInfo():
    def test_default_instance_has_factory_settings(self):
        car = Car()
        info = car.get_car_info()

        assert info['color'] == 'gray'
        assert info['has_gps'] is False
        assert info['interior_material'] is not 'leather'

# good
class TestGetCarInfo():
    def test_default_instance_color_is_gray(self):
        car = Car()

        assert car.get_car_info()['color'] == 'gray'

    def test_default_instance_does_not_have_gps(self):
        car = Car()

        assert car.get_car_info()['has_gps'] is False

    def test_default_instance_does_not_have_leather_interior(self):
        car = Car()

        assert car.get_car_info()['interior_material'] is not 'leather'

Discuss this guideline

Arrange, Act, Assert

While writing tests we often need to setup some variables or data structures, then call the method we want to test and after that assert that the results match what is expected. The Arrange, Act, Assert pattern enforces clear distinctions between these activities on a test file. All tests should be divided in up to three sections, creating a consistent reading experience.

  • First, arrange all the setup conditions for the test
  • Empty line
  • Second, act on the behavior being tested
  • Empty line
  • Third, assert expected results
# bad
def test_when_tank_is_full_then_returns_the_topped_off_amount(self)
    fuel_tank = FuelTank(max_volume=50)
    car = Car(fuel_tank=fuel_tank)
    topped_off = car.fill_tank(55)
    assert topped_off == 5

# good
def test_when_tank_is_full_then_returns_the_topped_off_amount(self)
    fuel_tank = FuelTank(max_volume=50)
    car = Car(fuel_tank=fuel_tank)

    topped_off = car.fill_tank(55)

    assert topped_off == 5

Mocking functions

Decorators become very nasty to read once you have more than one. The pytest-mock library description sums up the problem nicely.

  • test functions must receive the mock objects as parameter, even if you don’t plan to access them directly.
  • The argument order depends on the order of the decorated patch functions.
  • receiving the mocks as parameters doesn’t mix nicely with pytest’s approach of naming fixtures as parameters, or pytest.mark.parametrize;
  • you can’t easily undo the mocking during the test execution;
# bad
@patch.object(FuelTank, 'get_fuel_left', lambda self: 0)
def test_when_no_fuel_exists_returns_empty_1(self):
    mocked_tank = FuelTank()
    car = Car(fuel_tank=mocked_tank)

    assert car.get_remaining_fuel() == 'empty'

# good
def test_when_no_fuel_exists_returns_empty(self):
    mocked_tank = FuelTank()
    mocked_tank.get_fuel_left = MagicMock(return_value=0)
    car = Car(fuel_tank=mocked_tank)

    assert car.get_remaining_fuel() == 'empty'

Discuss this guideline

Mocking properties

When needing to mock a property, use the pytest-mock library. Decorators create too much code and become hard to read when needing to mock multiple properties. The mocker fixture provided by pytest-mock makes things clean and easy to read.

# bad
@patch.object(Car, 'condition', None)
@patch.object(Car, 'year', None)
@patch.object(Car, 'miles', None)
@patch.object(Car, 'base_price', None)
def test_returns_a_value(self):
    car = Car()
    car.condition = 'very good'
    car.year = 2005
    car.miles = 65000
    car.base_price = Decimal(25000)

    assert car.get_value() == Decimal('7812.5')

# good
def test_better_returns_a_value(self, mocker):
    car = Car()
    mocker.patch.object(Car, 'condition', 'very good')
    mocker.patch.object(Car, 'year', 2005)
    mocker.patch.object(Car, 'miles', 65000)
    mocker.patch.object(Car, 'base_price', Decimal(25000))

    assert car.get_value() == Decimal('7812.5')

Discuss this guideline

Mocking classes' attributes or methods

Don't do it in the class directly, or the mock will remain after the test finishes and will affect all the following tests that uses that class. With mocker.patch.object, the class will be rolled back to its original state at the end of the test.

# really bad (please, be carefull not to do this)
def test_returns_a_value(self, mocker):
    Car.condition = 'very good'
    Car.year = 2005
    Car.miles = 65000
    Car.base_price = Decimal(25000)
    Car.break = mocker.MagicMock()
    Car.a_class_or_static_method = mocker.MagicMock()
    
    car = Car()

    assert car.get_value() == Decimal('7812.5')

# good
def test_returns_a_value(self, mocker):
    mocker.patch.object(Car, 'a_class_or_static_method', mocker.MagicMock())

    car = Car(
        condition='very good',
        year=2005,
        miles=65000,
        base_price=Decimal(25000),
    )
    car.break = mocker.MagicMock()  # This is an instance, so it's fine to assign directly

    assert car.get_value() == Decimal('7812.5')

This is an exception where there's no discussion, just follow it and our test suite will be fine.

Acknowledgments