Por: @mariliars
Publicado em: 2018-08-28

Clean Architecture e o Desenvolvimento de Testes Doubles em Python

Uma das etapas da codificação dos produtos iTFLEX é o de desenvolvimento de testes para garantir o funcionamento do código. Atualmente, nossa equipe utiliza o pytest para escrever testes de código Python.

A refatoração dos códigos dos produtos iTFLEX para a estrutura Clean Architecture facilitou a implementação dos testes, pois cada camada da aplicação pode implementar um tipo de teste diferente. No entanto, é necessário escolher a abordagem mais adequada para cada camada da aplicação.

Nesse sentido, foi realizado um estudo sobre testes para entender qual a melhor forma de implementá-los.

Estudo

Este estudo incluiu os seguintes critérios de aceitação:

  • Pesquisar soluções para simplificar a criação de testes de APIs, casos de uso e repositórios
  • Elaborar um protótipo implementando a lógica

Testes Doubles

Teste double é um termo utilizado para casos em que se deseja substituir ou simular um objeto para fins de teste. São divididos nas seguintes categorias:

  • Stub: Fornece uma resposta fixa para chamadas de métodos e são independentes dos parâmetros de entrada do método. Enquadrado na abordagem de verificação de estado final do método que está sendo simulado.
  • Mock: Simula um objeto completo, no entanto retorna uma resposta fixa para o método que está sendo testado. Além disso, verifica se o método simulado recebeu o parâmetro esperado. Enquadrado na abordagem de verificação de comportamento do objeto que está sendo simulado.
  • Fake: É a implementação funcional do objeto. Não é necessário implementar todas as regras do métodos. Conhecido como “funcional para testes, mas incompletos para produção”.
  • Spies: São objetos que gravam suas intenções com outros objetos e são utilizados para testar callbacks.
  • Dummy: São objetos nulos. Portanto não fazem nada.

Saiba mais sobre testes double em xUnit Patterns - Test Double.

Protótipo

O protótipo foi elaborado considerando a API de usuário. Com o estudo sobre os conceitos de tests doubles percebeu-se que a implementação dos objetos simulados atualmente estão na estrutura de Fake Objects, no entando esta sendo chamado de Stub, como mostra o Código 01. Procurando simplificar a implementação destes objetos simulados para testar as APIs, foram desenvolvidos protótipos dos casos de uso de usuário com Stubs (Código 03 e Código 04).

Código 02: Fake objects dos casos de uso de usuário

# user_fake.py

class UsersUseCasesStub(UsersUseCases):
    def __init__(self):
        self._last_id = -1
        self._data = {}

    def _is_unique(self, user: User) -> bool:
        for id, db_user in self._data.items():
            if id == user.id:
                continue

            duplicated = False
            if user.username == db_user.username:
                duplicated = True
            if user.email == db_user.email:
                duplicated = True

            if duplicated:
                return False

        return True

    def reset(self):
        self._last_id = -1
        self._data = {}

    def create_user(self, req: CreateUser) -> UserResp:
        user = User(
            username=req.username,
            fullname=req.fullname,
            email=req.email,
            password_hash=req.password,
            superuser=req.superuser,
        )

        if not self._is_unique(user):
            return UserResp(
                status=Status.duplicated,
                errors=[
                    FieldError("username", ErrorTypes.duplicated),
                    FieldError("email", ErrorTypes.duplicated),
                ],
            )

        id = self._last_id + 1
        self._last_id = id

        user = user.replace(id=id)
        user = user.replace(created_at=datetime.now())
        user = user.replace(updated_at=datetime.now())
        self._data[id] = user

        return UserResp(user=user)

Código 02: Implementação dos testes das APIs dos usuário utilizando o fake object do Código 02

# test_user_api.py

@pytest.fixture
def users_uc_stub():
    return UsersUseCasesStub()


def test_get_user_by_id(
    client_app, headers_user, user_token: str, users_uc_stub: UsersUseCasesStub
):
    users_uc_stub.reset()
    user = CreateUser(
        token=user_token,
        username="user01",
        fullname="User Test",
        email="user01@email.com",
        password="teste",
        superuser=False,
    )
    users_uc_stub.create_user(user)

    res = client_app.get("/api/users/0", headers=headers_user)

    assert res.status_code == HTTPStatus.OK
    assert res.json == {
        "username": "user01",
        "email": "user01@email.com",
        "superuser": False,
        "fullname": "User Test",
        "id": 0,
    }

Código 03: Stub dos casos de uso de usuário

# user_stub.py

class UsersUseCasesStub(UsersUseCases):
    def __init__(self):
        self._resp = None
        self._data = {}

    def set_resp(self, resp) -> UserResp:
        self._resp = resp
        return self._resp

    def insert_data(self, user) -> UserResp:
        self._data[user.id] = user
        return self._data

    def reset(self):
        self._resp = None
        self._data = {}

    def create_user(self, req: CreateUser) -> UserResp:
        return self._resp

    # other methods

Código 04: Implementação dos testes das APIs dos usuário utilizando o stub do Código 03

# test_user_api.py

# imports

@pytest.fixture
def users_uc_stub():
    return UsersUseCasesStub()
    
    
@pytest.fixture
def user() -> User:
    return User(
        id=0,
        username="user01",
        fullname="User Test",
        email="user01@email.com",
        password_hash=passwords.encrypt("admin123"),
        superuser=False,
        created_at=datetime.datetime(2018, 7, 21, 10, 55, 15),
        updated_at=datetime.datetime(2018, 7, 21, 10, 55, 15),
    )


@pytest.fixture
def user_resp(user: user) -> UserResp:
    return UserResp(user=user)


@pytest.fixture
def user_resp_duplicated() -> UserResp:
    errors = [FieldError("email", ErrorTypes.duplicated)]
    return UserResp(status=Status.duplicated, errors=errors)


@pytest.fixture
def users_page_resp(user: User) -> UsersPage:
    return UsersPage(
        users=[user], cursor=0, next_cursor=None, previous_cursor=None
    )


def test_create_user_duplicated(
    client_app,
    headers_user,
    user_token: str,
    users_uc_stub: UsersUseCasesStub,
    user_resp_duplicated: UserResp,
    user: User,
):
    users_uc_stub.reset()
    users_uc_stub.insert_data(user)
    users_uc_stub.set_resp(user_resp_duplicated)

    res = client_app.post(
        "/api/users",
        json={
            "username": "other_user",
            "email": user.email,
            "superuser": False,
            "fullname": "User Test",
            "password": "teste",
        },
        headers=headers_user,
    )

    assert res.status_code == HTTPStatus.CONFLICT
    assert res.json == {"errors": [{"field": "email", "type": "duplicated"}]}

def test_get_user_by_id(
    client_app,
    headers_user,
    user_token: str,
    users_uc_stub: UsersUseCasesStub,
    user_resp: UserResp,
    user: User,
):
    users_uc_stub.reset()
    users_uc_stub.set_resp(user_resp)

    res = client_app.get("/api/users/0", headers=headers_user)

    assert res.status_code == HTTPStatus.OK
    assert res.json == {
        "id": user.id,
        "username": user.username,
        "email": user.email,
        "superuser": user.superuser,
        "fullname": user.fullname,
    }


def test_get_users_page(
    client_app,
    headers_user,
    user_token: str,
    users_uc_stub: UsersUseCasesStub,
    users_page_resp: UsersPage,
    user: User,
):
    users_uc_stub.reset()
    users_uc_stub.set_resp(users_page_resp)

    res = client_app.get("/api/users?cursor=0&size=15", headers=headers_user)

    assert res.status_code == HTTPStatus.OK
    assert res.json == {
        "users": [
            {
                "id": user.id,
                "username": user.username,
                "email": user.email,
                "superuser": user.superuser,
                "fullname": user.fullname,
            }
        ],
        "cursor": {"current": 0, "next": None, "previous": None},
    }
# other tests

Outra alternativa para a implementação dos testes foi utilizar oMock. O Código 05 e *Código 06 *apresentam, respectivamente, a implementação do Mock nos testes das APIs e dos casos de uso de usuário.

Código 05: Implementação dos testes das APIs dos usuário utilizando mock dos casos de uso

# test_user_api.py

# imports

@pytest.fixture
def users_uc_mock() -> UsersUseCases:
    return Mock(UsersUseCases)


def test_create_user(
    client_app,
    headers_user,
    user_token: str,
    user: User,
    users_uc_mock: UsersUseCases,
):
    users_uc_mock.create_user.return_value = UserResp(user=user)

    resp = client_app.post(
        "/api/users",
        json={
            "fullname": user.fullname,
            "username": user.username,
            "email": user.email,
            "password": user.password_hash,
            "superuser": user.superuser,
        },
        headers=headers_user,
    )

    assert resp.status_code == HTTPStatus.OK
    assert resp.json == {
        "id": user.id,
        "fullname": user.fullname,
        "username": user.username,
        "email": user.email,
        "superuser": user.superuser,
    }

    users_uc_mock.create_user.assert_called_with(
        CreateUser(
            token=user_token,
            fullname=user.fullname,
            username=user.username,
            email=user.email,
            password=user.password_hash,
            superuser=user.superuser,
        )
    )

# other tests

Código 06: Implementação dos testes dos casos de uso dos usuário utilizando mock do repositório e dos jobs em background

# test_user_use_case.py

# imports

@pytest.fixture
def users_repo_mock() -> UsersRepo:
    return Mock(UsersRepo)


@pytest.fixture
def users_jobs_mock() -> UsersJobs:
    return Mock(UsersJobs)


@pytest.fixture
def use_cases(
    users_repo_mock: UsersRepo, users_jobs_mock: UsersJobs
) -> UsersUseCases:
    return UsersUseCases(users_repo_mock, users_jobs_mock)


def db_call_side_effect(param, resp):
    def side_effect(user):
        assert user.fullname == param.fullname
        assert user.username == param.username
        assert user.email == param.email
        assert user.superuser == param.superuser
        assert user.password_hash.startswith("$pbkdf2-sha512$")
        return resp

    return side_effect


@freeze_time(EXAMPLE_DT)
def test_create_user(
    user_token: str,
    user: User,
    use_cases: UsersUseCases,
    users_repo_mock: UsersRepo,
):
    request = CreateUser(
        token=user_token,
        fullname=user.fullname,
        username=user.username,
        email=user.email,
        password=user.password_hash,
        superuser=user.superuser,
    )

    user_resp = UserResp(user=user)
    users_repo_mock.insert_user.side_effect = db_call_side_effect(
        user, user_resp
    )

    resp = use_cases.create_user(request)

    assert resp.ok
    assert resp.user == user


def test_get_user_by_id(
    user_token: str,
    user: User,
    use_cases: UsersUseCases,
    users_repo_mock: UsersRepo,
):
    users_repo_mock.get_user_by_id.return_value = UserResp(user=user)

    request = GetUserById(token=user_token, id=user.id)
    resp = use_cases.get_user_by_id(request)

    assert resp.ok
    assert resp.user == user

    users_repo_mock.get_user_by_id.assert_called_with(user.id)

Materiais de apoio: