Przejdź do głównej zawartości

Integracja testów

Twój przepływ checkoutu przechodzi każdy test jednostkowy, plakietka pokrycia mówi 94%, a mimo to zwraca 500 na produkcji, bo nic nigdy nie przećwiczyło ścieżki, na której dostawca płatności przekracza limit czasu w trakcie żądania. Testy były zielone. Testowały też nie to, co trzeba — głównie Twoje mocki sprawdzające, że Twoje mocki zostały wywołane. To, czego potrzebujesz, to zestaw, który wychwytuje błędy integracyjne, a nie ściana zielonych ptaszków, które Cię okłamują.

Tutaj agent kodujący AI zarabia na swoje utrzymanie. Nie przez generowanie setki trywialnych testów expect(sum(1,2)).toBe(3), ale przez czytanie Twojego prawdziwego kodu, znajdowanie trybów awarii, które pominąłeś, i pisanie testów, które nie przechodzą z właściwych powodów. Ten artykuł pokazuje ten przepływ pracy w Cursorze, Claude Code i Codeksie.

  • Pętlę TDD, w której agent najpierw pisze test, który nie przechodzi, a potem implementację — żebyś wiedział, że test naprawdę potrafi nie przejść
  • Wielokrotnie używalny prompt, który generuje testy integracyjne dla trasy Express + Drizzle, włącznie ze ścieżką awarii bazy danych potwierdzającą prawdziwe 503
  • Prompt do E2E w Playwright, który przeżywa w CI zamiast sypać się przy trzecim uruchomieniu
  • Powtarzalny sposób naprawiania niestabilnych testów przez atakowanie przyczyny źródłowej, a nie zaklejanie jej sleep()
  • Bezgłowe polecenie testowe claude -p oraz przepis .claude/commands, który możesz wrzucić do prawdziwego repozytorium już dziś

Przez cały artykuł będziemy testować jeden prawdziwy endpoint: trasę Express, która tworzy zamówienie, opartą na Drizzle ORM na Postgresie. Nic tu nie jest zabawką — ma dwie rzeczy, które psują się na produkcji: zewnętrzne wywołanie (dostawca płatności) i zapis do bazy danych, który może się nie powieść.

src/routes/orders.ts
import { Router } from 'express';
import { db } from '../db';
import { orders } from '../db/schema';
import { charge } from '../lib/payments';
export const ordersRouter = Router();
ordersRouter.post('/orders', async (req, res) => {
const { userId, amountCents, idempotencyKey } = req.body;
if (!userId || !amountCents) {
return res.status(400).json({ error: 'userId and amountCents required' });
}
try {
const payment = await charge({ amountCents, idempotencyKey });
const [order] = await db
.insert(orders)
.values({ userId, amountCents, paymentId: payment.id, status: 'paid' })
.returning();
return res.status(201).json(order);
} catch (err) {
if (err instanceof PaymentError) return res.status(402).json({ error: 'payment_failed' });
// DB write failed after a successful charge -- the dangerous case
return res.status(503).json({ error: 'order_persist_failed', retryable: true });
}
});

Interesujący test to nie „201 na ścieżce szczęśliwej”. To: obciążenie się powiodło, ale insert do bazy rzucił wyjątek — czy zwracamy 503 z możliwością ponowienia i czy unikamy podwójnego obciążenia przy ponowieniu? To właśnie ten błąd budzi Cię telefonem o 2 w nocy.

Dyscyplina, która sprawia, że testy generowane przez AI są godne zaufania, jest prosta: spraw, by test nie przechodził, zanim pozwolisz agentowi cokolwiek zaimplementować. Test, który nigdy nie był czerwony, nie jest testem — to komentarz. Przeprowadź agenta przez czerwony → zielony → refaktoryzacja w sposób jawny.

  1. Najpierw napisz test, który nie przechodzi. Każ agentowi napisać test dla zachowania, które jeszcze nie istnieje, i zatrzymać się przed implementacją.

  2. Uruchom go i potwierdź, że nie przechodzi z właściwego powodu. Nie literówka, nie brakujący import — prawdziwa porażka asercji albo 404 na trasie, której jeszcze nie zbudowałeś.

  3. Zaimplementuj do zielonego. Pozwól agentowi napisać minimum kodu potrzebnego do przejścia, z wyraźną instrukcją, by nie dotykał testu.

  4. Refaktoryzuj pod zielonym paskiem. Teraz zestaw jest Twoją siatką bezpieczeństwa przy porządkach.

Mechanika prowadzenia tej pętli różni się w zależności od narzędzia. Prompt jest niemal identyczny; sposób utrzymywania agenta w ryzach — już nie.

Użyj trybu Agent i oprzyj się na checkpointach. Przed krokiem implementacji test, który nie przechodzi, jest naturalnym checkpointem — jeśli agent „naprawi” test zamiast kodu (klasyczna porażka), przywróć ten checkpoint i ponów prompt.

W Composerze, mając orders.ts oraz pusty orders.test.ts w kontekście:

Write a Vitest integration test for POST /orders covering the case where
charge() resolves but the Drizzle insert rejects. Assert a 503 with
{ retryable: true }. Do NOT implement the route yet -- the test must fail.

Uruchom npm run test w terminalu Cursora, zobacz, jak robi się czerwony, a potem rozpocznij nowy prompt: „Now make this pass without editing the test file.” Trzymaj włączone „Iterate on lints”, aby błędy typów były naprawiane w tej samej turze.

Awaria z początkowego scenariusza wydarzyła się na szwie — granicy między Twoim kodem a zewnętrzną usługą. Nadmiernie zmockowane zestawy przechodzą właśnie dlatego, że nigdy nie dotykają tych szwów. Lekarstwem jest mockowanie na krawędziach (granica HTTP dostawcy płatności, zachowanie awarii bazy danych) i uruchamianie wszystkiego pomiędzy naprawdę.

Mocny prompt do testów integracyjnych jest konkretny w trzech sprawach: co zmockować, co uruchomić naprawdę i które ścieżki błędów są obowiązkowe.

Oto kształt tego, co dobry agent produkuje dla niebezpiecznego przypadku — zwróć uwagę, że potwierdza zachowanie (status, treść, liczbę wywołań), nigdy szczegóły implementacji:

import request from 'supertest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { app } from '../app';
import * as payments from '../lib/payments';
import { db } from '../db';
beforeEach(() => vi.restoreAllMocks());
it('returns retryable 503 when the DB write fails after a charge', async () => {
vi.spyOn(payments, 'charge').mockResolvedValue({ id: 'pay_123' });
vi.spyOn(db, 'insert').mockImplementation(() => {
throw new Error('connection terminated');
});
const res = await request(app)
.post('/orders')
.send({ userId: 'u1', amountCents: 4999, idempotencyKey: 'k1' });
expect(res.status).toBe(503);
expect(res.body).toMatchObject({ retryable: true });
expect(payments.charge).toHaveBeenCalledTimes(1);
});

Jeśli na potrzeby testów integracyjnych uruchamiasz prawdziwego Postgresa zamiast mockować bazę, serwer Postgres MCP (@modelcontextprotocol/server-postgres) pozwala agentowi przejrzeć Twój żywy schemat i pisać testy zgodne z prawdziwymi ograniczeniami kolumn, a nie z jego zgadywaniem schematu. Podłącz go raz, a prompt zmienia się z „załóż schemat” na „odczytaj tabelę orders i potwierdź jej prawdziwe ograniczenia NOT NULL”. Dla E2E w przeglądarce Playwright MCP (@playwright/mcp) pozwala agentowi sterować prawdziwą stroną i czytać DOM podczas pisania testu, zamiast wymyślać selektory.

Testy E2E nie przechodzą w CI z jednego powodu częściej niż z jakiegokolwiek innego: test ściga się z aplikacją. Agent kliknął, zanim przycisk był interaktywny, potwierdził, zanim wywołanie sieciowe się rozwiązało, albo polegał na sztywnym waitForTimeout. Lekarstwem jest zakazanie arbitralnych oczekiwań oraz wymuszenie asercji web-first i dostępnych lokatorów.

Instrukcja „uruchom to 3 razy” na końcu wykonuje realną pracę: zamienia jednorazowe generowanie w sprawdzenie stabilności, zanim test w ogóle dotrze do Twojego potoku.

Korzyść z bezgłowych agentów polega na tym, że generowanie testów i triage mogą działać w CI, nie tylko na Twoim laptopie. Te trzy narzędzia idą różnymi drogami.

Cursor jest przede wszystkim IDE, więc połowę z CI stanowi Twój normalny runner testów — wartością Cursora jest tworzenie. Realistyczne zadanie GitHub Actions, które uruchamia zestaw Vitest, który Cursor pomógł Ci napisać:

.github/workflows/test.yml
name: test
on: [pull_request]
jobs:
vitest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test -- --coverage

Użyj Background Agent Cursora, by szkicował testy dla nowej trasy na gałęzi, podczas gdy Ty pracujesz dalej, a potem przejrzyj diff, zanim trafi do tego potoku.

Wielokrotnie używalne polecenie slash do generowania testów

Dział zatytułowany „Wielokrotnie używalne polecenie slash do generowania testów”

Katalog .claude/commands/ zamienia dobry prompt w jednowyrazowe polecenie. Sztuczka, którą pomija wersja generyczna: nazwij stos i obowiązkowe ścieżki błędów w treści polecenia, tak by każde wywołanie produkowało prawdziwy przepis zamiast mglistych „kompleksowych testów”.

.claude/commands/test-route.md
Write Vitest + supertest integration tests for the Express route: $ARGUMENTS
Stack: Express, Drizzle ORM (Postgres), Vitest, supertest.
Run the real middleware + handler. Mock only external services and the DB.
Always include these cases, one it() each:
- happy path (correct status + persisted row)
- input validation (400 on missing required fields)
- external dependency throws (assert the mapped error status, e.g. 402)
- DB write fails AFTER an external side effect (assert a retryable 5xx)
- idempotency: a repeated request with the same key has no double effect
Assert status codes and specific body fields. No snapshot tests.
Reset mocks in beforeEach. Run the suite and report pass/fail per case.

Wywołaj je w sesji Claude Code przez /test-route POST /orders. Ta sama treść promptu działa jako zapisany prompt w Cursorze lub prompt w Codeksie — przepis jest przenośny; różni się tylko sposób wywołania.

Zestaw jest zielony, ale produkcja nadal się psuje. Testujesz mocki, nie szwy. Mockowanie funkcji będącej przedmiotem testu sprawia, że asercja jest błędnym kołem. Uruchom ponownie powyższy prompt integracyjny i wymuś prawdziwe wykonanie wszystkiego poza prawdziwą granicą zewnętrzną (wywołanie HTTP płatności, sterownik bazy). Jeśli Twój mock i prawdziwe API się rozjeżdżają, dodaj test kontraktowy, który co noc uderza w endpoint sandboksowy.

Agent „naprawił” niezdający test, osłabiając asercję. To najczęstsza porażka testowania z AI: poproszony o przepuszczenie testów, edytuje test zamiast kodu. Zapobiegaj temu strukturalnie — w Claude Code pomiń Edit na ścieżce testu w --allowedTools podczas kroku implementacji; w Cursorze przywróć checkpoint sprzed implementacji i ponów prompt z „without editing the test file”.

E2E przechodzi lokalnie, sypie się w CI. Prawie zawsze wyścig. Przeszukaj wygenerowany test pod kątem waitForTimeout i usuń każde trafienie, a potem poproś agenta, by zastąpił każde asercją web-first lub toHaveURL. CI jest wolniejsze niż Twój laptop, więc każde sztywne oczekiwanie, które „działa” lokalnie, to bomba zegarowa.

Testy potwierdzają implementację, nie zachowanie. Jeśli zmiana nazwy prywatnej metody psuje dwadzieścia testów, agent zbyt mocno je sprzęgnął. Prompt: „These tests break on safe refactors. Rewrite them to assert observable behavior — inputs, outputs, status codes, persisted state — never private method names or call order unless ordering is the contract.”

Pokrycie jest wysokie, ale błędy nadal trafiają na produkcję. Pokrycie mierzy wykonane linie, nie wykonane asercje. Test może uruchomić linię i niczego nie potwierdzić. Poproś agenta o test mutacyjny krytycznego modułu: „Introduce three plausible bugs in src/routes/orders.ts one at a time and tell me which tests catch each. Any bug that nothing catches reveals a missing assertion.”