Funkcja może mieć 100% pokrycia testami jednostkowymi, a strona, która ją wywołuje, i tak może zwrócić w produkcji błąd 500. To nie jest sprzeczność. Testy jednostkowe, integracyjne i end-to-end sprawdzają różne rzeczy, a zestaw testów mocny w jednym i słaby w drugim ma konkretną, przewidywalną dziurę.

Co sprawdzają testy jednostkowe, integracyjne i end-to-end

Weźmy funkcję rejestracji: funkcja waliduje dane wejściowe, zapisuje wiersz w bazie danych i wysyła email powitalny. Trzy testy mogą to pokryć, każdy odpowiada na inne pytanie.

PoziomNa jakie pytanie odpowiadaCo jest prawdziwe, co jest podmienione
JednostkowyCzy ta jedna funkcja robi to, co powinna?Wszystko wokół funkcji jest podmienione: brak prawdziwej bazy danych, brak prawdziwej sieci
IntegracyjnyCzy ta funkcja i prawdziwa zależność współpracują ze sobą?Baza danych jest prawdziwa; części wciąż poza testowaną granicą (serwer pocztowy) są podmienione
End-to-endCzy całe żądanie działa, patrząc z zewnątrz?Nic nie jest podmienione. Prawdziwe żądanie HTTP trafia do prawdziwego działającego procesu

Test jednostkowy mówi ci, że walidacja formatu emaila odrzuca to, co powinna odrzucić. Test integracyjny mówi ci, że wiersz naprawdę trafia do bazy danych, do kolumn, które — jak zakładasz — tam są. Test end-to-end mówi ci, że klient może wywołać endpoint i dostać w odpowiedzi 201. Żaden z nich nie zastąpi pozostałych.

Testowanie jednej funkcji we Flasku na trzech poziomach

Oto funkcja: sign_up, która waliduje email, tworzy użytkownika i wysyła wiadomość powitalną.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# app.py
import os
import smtplib
import sqlite3

from flask import Flask, jsonify, request


def get_db():
    db_path = os.environ.get("DB_PATH", "app.db")
    return sqlite3.connect(db_path)


def get_smtp_client():
    host = os.environ.get("SMTP_HOST", "localhost")
    port = int(os.environ.get("SMTP_PORT", "25"))
    return smtplib.SMTP(host, port)


def init_db(db):
    db.execute("CREATE TABLE IF NOT EXISTS users (email TEXT PRIMARY KEY, name TEXT)")
    db.commit()


def create_user(db, email, name):
    db.execute("INSERT INTO users (email, name) VALUES (?, ?)", (email, name))
    db.commit()


def send_welcome_email(smtp_client, to_address, name):
    smtp_client.sendmail(
        "[email protected]",
        to_address,
        f"Subject: Welcome\n\nHi {name}, thanks for signing up.",
    )


def sign_up(db, smtp_client, email, name):
    if not email or "@" not in email:
        raise ValueError("invalid email")
    create_user(db, email, name)
    send_welcome_email(smtp_client, email, name)
    return {"email": email, "name": name, "status": "created"}


app = Flask(__name__)


@app.post("/signup")
def signup_route():
    data = request.get_json()
    db = get_db()
    init_db(db)
    smtp_client = get_smtp_client()
    result = sign_up(db, smtp_client, data["email"], data["name"])
    smtp_client.quit()
    return jsonify(result), 201


@app.get("/users/<email>")
def get_user_route(email):
    db = get_db()
    row = db.execute("SELECT email, name FROM users WHERE email = ?", (email,)).fetchone()
    if row is None:
        return jsonify({"error": "not found"}), 404
    return jsonify({"email": row[0], "name": row[1]})

sign_up przyjmuje db i smtp_client jako argumenty zamiast korzystać z globalnego połączenia. Ta jedna decyzja umożliwia wszystkie trzy testy poniżej: każdy z nich przekazuje w tych dwóch parametrach coś innego. get_db() i get_smtp_client() przy każdym wywołaniu odczytują swój cel ze zmiennej środowiskowej, zamiast zaszywać go na stałe. Dzięki temu test end-to-end poniżej może skierować działającą aplikację na jednorazową bazę danych i lokalny testowy serwer pocztowy, bez dotykania app.py.

Test jednostkowy: podmień obie zależności

Test jednostkowy całkowicie izoluje sign_up. Ani baza danych, ani klient SMTP nie są prawdziwe; oba to obiekty Mock ze standardowej biblioteki Pythona.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# test_unit.py
from unittest.mock import Mock

import pytest

from app import sign_up


def test_sign_up_rejects_invalid_email():
    db, smtp_client = Mock(), Mock()
    with pytest.raises(ValueError):
        sign_up(db, smtp_client, "not-an-email", "Ada")
    db.execute.assert_not_called()


def test_sign_up_sends_welcome_email():
    db, smtp_client = Mock(), Mock()
    sign_up(db, smtp_client, "[email protected]", "Ada")
    smtp_client.sendmail.assert_called_once()
    to_address = smtp_client.sendmail.call_args.args[1]
    assert to_address == "[email protected]"

Wchodzą tu w grę dwa rodzaje test double, a nazwy mają znaczenie, bo ustawiają różne oczekiwania. db w pierwszym teście jest bliskie dummy: jest przekazywane, bo sygnatura wymaga czegoś, a testowi zależy tylko na tym, żeby nigdy nie zostało dotknięte. smtp_client w drugim teście to mock w ścisłym sensie. Test z góry definiuje oczekiwanie (sendmail zostanie wywołane z tym adresem) i kończy się niepowodzeniem, jeśli rzeczywistość się z nim nie zgadza. To weryfikacja zachowania: sprawdzasz, że wywołanie się odbyło, a nie co z niego wróciło.

Te testy działają w milisekundach i nigdy nie dotykają dysku ani socketu. Nie powiedzą ci też, czy INSERT INTO users odpowiada rzeczywistym kolumnom tabeli. db nigdy nie wykonuje niczego prawdziwego, więc literówka w SQL przeszłaby przez każdy napisany przez ciebie test jednostkowy.

Test integracyjny: prawdziwa baza danych, fałszywy serwer pocztowy

Test integracyjny zamyka tę lukę, ale jedną zależność naraz. Tutaj baza danych jest prawdziwa — połączenie SQLite w pamięci z rzeczywistym schematem — a serwer pocztowy nadal jest podmieniony. Tym razem to fake: mała, działająca implementacja zamiast zaskryptowanego zastępstwa.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# test_integration.py
import sqlite3

from app import init_db, sign_up


class FakeSMTP:
    def __init__(self):
        self.sent = []

    def sendmail(self, from_addr, to_addr, message):
        self.sent.append((to_addr, message))


def test_sign_up_persists_user_and_records_email():
    db = sqlite3.connect(":memory:")
    init_db(db)
    smtp_client = FakeSMTP()

    sign_up(db, smtp_client, "[email protected]", "Grace")

    row = db.execute(
        "SELECT email, name FROM users WHERE email = ?", ("[email protected]",)
    ).fetchone()
    assert row == ("[email protected]", "Grace")
    assert smtp_client.sent[0][0] == "[email protected]"

FakeSMTP nie ma z góry zaskryptowanych oczekiwań, tak jak robiło to smtp_client.sendmail.assert_called_once(). Trzyma prawdziwą, choć maleńką, listę tego, co zostało wysłane, a test odczytuje ją później. To jest różnica między mockiem a fake’em: mockowi mówisz, czego się spodziewać, zanim kod się wykona; fake zachowuje się jak mniejsza wersja prawdziwej rzeczy i sprawdzasz go dopiero potem.

Ten test wyłapie złą nazwę kolumny albo brakującą tabelę. Nie wyłapie błędu w parsowaniu żądań przez Flask ani złego URL-a w dekoratorze trasy, bo nic tutaj nie przechodzi przez HTTP.

Test end-to-end: prawdziwe żądanie do działającego procesu

Test end-to-end usuwa obie podmiany. Uruchamia prawdziwą aplikację Flask jako osobny proces, wysyła do niej prawdziwe żądanie HTTP przez prawdziwy socket i sprawdza wynik tak, jak zrobiłby to zewnętrzny klient: wywołując ponownie API, a nie zaglądając do bazy danych.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# test_e2e.py
import multiprocessing
import os
import tempfile
import time

import requests
from aiosmtpd.controller import Controller

from app import app


class AcceptAllHandler:
    async def handle_DATA(self, server, session, envelope):
        return "250 Message accepted for delivery"


def run_server():
    app.run(port=5055)


def test_signup_end_to_end():
    db_fd, db_path = tempfile.mkstemp(suffix=".db")
    os.close(db_fd)
    os.environ["DB_PATH"] = db_path
    os.environ["SMTP_HOST"] = "127.0.0.1"
    os.environ["SMTP_PORT"] = "1025"

    mail_server = Controller(AcceptAllHandler(), hostname="127.0.0.1", port=1025)
    mail_server.start()

    server = multiprocessing.Process(target=run_server)
    server.start()
    time.sleep(0.5)  # daj serwerowi deweloperskiemu Flaska chwilę na zbindowanie portu

    try:
        signup = requests.post(
            "http://127.0.0.1:5055/signup",
            json={"email": "[email protected]", "name": "Amelia"},
        )
        assert signup.status_code == 201

        lookup = requests.get("http://127.0.0.1:5055/users/[email protected]")
        assert lookup.json() == {"email": "[email protected]", "name": "Amelia"}
    finally:
        server.terminate()
        mail_server.stop()
        os.remove(db_path)

aiosmtpd zastępuje przechwytywacz poczty, który uruchomiłbyś w środowisku staging: coś, co naprawdę mówi po SMTP przez socket, dzięki czemu prawdziwe wywołanie smtplib.SMTP(...) w aplikacji ma się gdzie połączyć. Test tworzy też sobie jednorazowy plik bazy danych zamiast ponownie używać app.db: uruchomienie tego testu dwa razy na współdzielonym pliku zakończyłoby się błędem za drugim razem na tym samym PRIMARY KEY, który już zajmuje [email protected] — dokładnie taki rodzaj niestabilności wynikającej z pozostałości stanu, o którym artykuł mówi w dalszej części. Nic w tym teście nie wie, że sign_up czy create_user w ogóle istnieją. Zna tylko kontrakt HTTP. Zmień nazwę trasy albo kształt JSON-a, a to właśnie ten test się wysypie, podczas gdy testy jednostkowe i integracyjne powyżej nadal będą przechodzić.

Zainstaluj zależności poleceniem pip install flask requests aiosmtpd pytest i uruchom wszystkie trzy przez pytest. Kiedy coś się psuje, sam fakt, który plik testów zawiódł, mówi ci, z jaką klasą błędu masz do czynienia. To dobry powód, żeby pisać testy na więcej niż jednym poziomie. Procent pokrycia takim powodem nie jest.

Piramida testów vs trofeum testów

Klasycznym kształtem dla zestawu testów jest piramida testów Mike’a Cohna: szeroka podstawa z szybkich testów jednostkowych, mniejsza warstwa testów integracyjnych i cienki czubek wolnych, kosztownych testów end-to-end. Liczy się proporcja, nie liczba. Większość twoich testów powinna być tego taniego rodzaju.

Trofeum testów Kenta C. Doddsa nie zgadza się co do tego, gdzie powinien leżeć ciężar. Jego argument: izolowane testy jednostkowe dają pewność, którą łatwo zdobyć, ale która nie odzwierciedla tego, jak oprogramowanie jest naprawdę używane, więc zestaw testów złożony głównie z testów jednostkowych może przeoczyć realne błędy na styku komponentów. Trofeum kładzie analizę statyczną (linting, sprawdzanie typów) na dole, warstwę jednostkową nad nią, testy integracyjne jako najszerszą część, a cienką warstwę end-to-end na samej górze. Trofeum chce więcej testów w stylu test_sign_up_persists_user_and_records_email powyżej.

Piramida (Cohn)Trofeum (Dodds)
Największa warstwaJednostkowaIntegracyjna
Warstwa u podstawyTesty jednostkoweAnaliza statyczna (lint, typy)
Pytanie przewodnieJak tani jest ten test do napisania i uruchomienia?Jak bardzo ten test przypomina prawdziwe użycie?
Słaby punkt, w który celujeWolne, kruche zestawy end-to-endTesty jednostkowe, które przechodzą, mimo że styki są zepsute

Nie trzymaj się żadnego z tych kształtów dosłownie. Zgadzają się co do kolejności: testy end-to-end są najdroższe i powinno ich być najmniej, testy jednostkowe są najtańsze. Gdzie skierujesz największy wysiłek, zależy od historii błędów w twoim projekcie. Jeśli większość błędów to komponenty źle zachowujące się same z siebie, przechyl się w stronę piramidy. Jeśli większość to komponenty, które nie zgadzają się co do sposobu, w jaki ze sobą rozmawiają, przechyl się w stronę trofeum.

Dlaczego testy end-to-end stają się niestabilne

Zestaw z rosnącym stosem testów end-to-end w końcu zyskuje złą reputację: ponownie uruchamiasz test, który padł, i przechodzi. To jest niestabilność (flakiness) i bierze się z tej samej właściwości, która czyni testy end-to-end wartościowymi. Dotykają prawdziwej infrastruktury.

Zwykłe przyczyny to sztywny sleep(), który wystarcza na twoim laptopie, a jest za krótki na obciążonym runnerze CI, dane testowe pozostawione przez poprzednie uruchomienie, które zderzają się z kolejnym, wywołanie sieciowe do czegoś poza twoją kontrolą oraz stan współdzielony między testami, który ujawnia się dopiero, gdy uruchamiają się w innej kolejności albo równolegle. time.sleep(0.5) w teście powyżej to pierwszy rodzaj; plik tymczasowy, który ten sam test tworzy dla DB_PATH, to bezpośrednia poprawka drugiego. Żaden z nich nie jest błędem w funkcjonalności. To błędy w tym, jak test radzi sobie z prawdziwą infrastrukturą — problem, którego testy jednostkowe nigdy nie mają.

Ponawianie testu, aż będzie zielony, nie jest rozwiązaniem; to chowa następną prawdziwą awarię w szumie. Trzymaj warstwę end-to-end cienką, pokrywając krytyczne ścieżki, a nie każdą gałąź. Daj każdemu testowi jego własne dane zamiast współdzielonych fixture’ów. Zamień sztywne opóźnienia na pętlę sprawdzającą konkretny warunek, na przykład to, czy port już przyjmuje połączenia. Czas CI to drugi koszt: jeśli już przyspieszyłeś pipeline, cache’ując zależności w GitHub Actions, warstwa end-to-end, która potrzebuje trzech ponownych uruchomień, żeby zrobić się zielona, oddaje sporą część tego czasu.

Jaki test napisać dalej

Jeśli błąd właśnie wyciekł na produkcję, zanim dodasz jakikolwiek test, zapytaj, który poziom by go złapał. Błędne obliczenie albo nieobsłużony przypadek brzegowy w jednej funkcji woła o test jednostkowy. Dwa fragmenty twojego własnego kodu, które nie zgadzają się co do kształtu albo kontraktu — na przykład API zwracające pole, którego frontend się nie spodziewa, albo zapytanie zakładające kolumnę, która została zmieniona — wołają o test integracyjny. Jeśli funkcja przechodzi każdy test, ale zawodzi u prawdziwego użytkownika, zwykle brakuje jej pokrycia end-to-end na ścieżce, którą przeszedł ten użytkownik.

Napisz najpierw test jednostkowy. To najtańszy sygnał i najszybciej mówi ci, kiedy zepsułeś logikę. Dodaj test integracyjny, kiedy dwa twoje własne elementy muszą udowodnić, że się ze sobą zgadzają. Zachowaj testy end-to-end dla garstki przepływów, w których „działa” znaczy coś wyłącznie z perspektywy kogoś z zewnątrz: rejestracja, logowanie, checkout, resetowanie hasła.

Trzymaj się tego podziału, a dostaniesz zestaw testów, którego awarie coś znaczą. Ma to znaczenie poza CI. Git bisect z git bisect run potrzebuje komendy testowej, która zawodzi tylko wtedy, gdy kod jest naprawdę zepsuty, a niestabilny test end-to-end wyśle go do złego commita.

Powiązane artykuły