Writing Tests with pytest and pytest-factoryboy
Tags: testing, python
Published: 2019-08-20 22:21:30


Testing in python can be tricky so I've outlined what works for me. I like to use [pytest](https://docs.pytest.org/en/latest/) with [pytest-factoryboy](https://github.com/pytest-dev/pytest-factoryboy) to generate testing data. `pytest-factoryboy` is an easier way to test than having to maintain fixtures in a test database.


## Define the factories. They look very similar to models. 

```python
# tests/factories.py 

import factory
from faker import Factory as FakerFactory

from django.contrib.auth.models import User
from django.utils.timezone import now

from app.models import Game, Message, GamePlayer, Round, Move

faker = FakerFactory.create()


class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    email = factory.Faker("safe_email")
    username = factory.LazyAttribute(lambda x: faker.name())

    @classmethod
    def _prepare(cls, create, **kwargs):
        password = kwargs.pop("password", None)
        user = super(UserFactory, cls)._prepare(create, **kwargs)
        if password:
            user.set_password(password)
            if create:
                user.save()
        return user


class GameFactory(factory.django.DjangoModelFactory):
    room_name = factory.LazyAttribute(lambda x: faker.name())
    game_status = "active"
    created_at = factory.LazyAttribute(lambda x: now())
    updated_at = factory.LazyAttribute(lambda x: now())
    round_started = False
    is_joinable = True

    class Meta:
        model = Game


class GamePlayerFactory(factory.django.DjangoModelFactory):
    followers = 0
    selfies = 3
    user = factory.SubFactory(UserFactory)
    started = False
    game = factory.SubFactory(GameFactory)

    class Meta:
        model = GamePlayer


class MessageFactory(factory.django.DjangoModelFactory):
    game = factory.SubFactory(GameFactory)
    username = None
    message = factory.LazyAttribute(lambda x: faker.sentence())
    created_at = factory.LazyAttribute(lambda x: now())
    message_type = None

    class Meta:
        model = Message

# etc 
```

## In a `conftest.py` file I register the factories. 

Fixtures defined in [`conftest.py`](https://stackoverflow.com/questions/34466027/in-pytest-what-is-the-use-of-conftest-py-files) file will be recognized in any test file. "Registering" the factories lets you inject them into your tests with a **lowercase-underscore class name.** In this same file, I'm defining fixtures with `@pytest.fixture()` which I'll use in my tests, and injecting them with the factories I just registered.  

```python 
# tests/conftest.py 
import pytest
from pytest_factoryboy import register

from .factories import (
    UserFactory,
    GameFactory,
    GamePlayerFactory,
    MoveFactory,
    RoundFactory,
)

register(UserFactory)
register(GameFactory)
register(GamePlayerFactory)
register(MoveFactory)
register(RoundFactory)


@pytest.fixture()
def game(game_factory):
    return game_factory()


@pytest.fixture()
def rnd(game, round_factory):
    return round_factory(game=game, started=True)


@pytest.fixture()
def p_1(game, user_factory, game_player_factory):
    return game_player_factory(game=game, user=user_factory(), started=True)

#etc...more player fixtures defined
```

## Write tests 

Here I'm writing tests. I'm passing in my fixture data into my test method, and also passing in a factory called `move` because that's what I'm mainly testing here. In order to test player moves, I first needed to have created the players, the game, and the round, which is what I did in my `conftest.py` file. I only need to do this once, and I don't need to import the fixtures because anything declared in `conftest.py` is visible to the test files. 

```python
# tests/test_round_service.py

import pytest

from app.services.round_service import (
    RoundTabulation,
    LEAVE_COMMENT,
    CALL_IPHONE,
    DISLIKE,
    GO_LIVE,
    POST_SELFIE,
    DONT_POST,
    NO_MOVE,
    DISLIKE_DM,
    POST_SELFIE_PTS,
    POST_SELFIE_DM,
    GO_LIVE_DM,
    NO_MOVE_DM,
)
from app.services import round_service, message_service
from app.models import Move, Message, GamePlayer


@pytest.mark.django_db
def test_iphone_go_live_do_selfie(rnd, p_1, p_2, p_3, move_factory):
    """if a girl calls a girl who is trying to take a selfie, she sustains go
    live damage"""
    move_factory(round=rnd, action_type=GO_LIVE, player=p_1, victim=None)
    move_factory(round=rnd, action_type=CALL_IPHONE, player=p_2, victim=p_3)
    move_factory(round=rnd, action_type=POST_SELFIE, player=p_3, victim=None)
    tab = RoundTabulation(rnd).tabulate()
    assert tab[p_1.id] == 0
    assert tab[p_2.id] == GO_LIVE_DM
    assert tab[p_3.id] == GO_LIVE_DM


@pytest.mark.django_db
def test_go_live_with_call(rnd, p_1, p_2, p_3, p_4, p_5, move_factory):
    """message was created, points are corrected, player is removed from player
    points array, they have one less story"""
    move_factory(round=rnd, action_type=GO_LIVE, player=p_1, victim=None)
    move = move_factory(round=rnd, action_type=CALL_IPHONE, player=p_2, victim=p_1)
    move_factory(round=rnd, action_type=DISLIKE, player=p_3, victim=p_1)
    move_factory(round=rnd, action_type=DISLIKE, player=p_4, victim=p_1)
    move_factory(round=rnd, action_type=LEAVE_COMMENT, player=p_5, victim=p_1)
    tab = RoundTabulation(rnd).tabulate()
    assert message(rnd.game, p_2.user.username) in message_service.iphone_msg(
        move, p_1.user.username
    )
    p_1 = GamePlayer.objects.get(id=p_1.id)
    assert p_1.go_live == 1
    assert tab[p_1.id] == -50

```
## Execute the tests 

Now your tests can run with test data! 
```
[17:59:32] (master) selfies
🙋 docker exec -it 5c93db3f3183 bash
root@5c93db3f3183:/selfies# pytest app/tests/test_round_service.py
======================================= test session starts ========================================
platform linux -- Python 3.7.4, pytest-5.0.1, py-1.8.0, pluggy-0.12.0
Django settings: selfies.settings (from ini file)
rootdir: /selfies, inifile: pytest.ini
plugins: factoryboy-2.0.3, django-3.5.1
collected 15 items                                                                                 

app/tests/test_round_service.py ...............
```