Toate ghidurile

Testarea codului: unit, integration și end-to-end

Cele trei niveluri de testare explicate cu exemple în Vitest, pytest și Playwright, plus ce merită testat și cum eviți testele instabile.


Ai schimbat o linie de cod și, două zile mai târziu, afli că s-a stricat o funcționalitate aparent fără legătură. Testele automate există exact ca să prevină asta: sunt o plasă de siguranță care îți spune în câteva secunde dacă ceva s-a rupt. În acest ghid vezi cele trei niveluri principale de testare (unit, integration și end-to-end), cu exemple concrete și criterii clare despre ce merită testat.

De ce merită să scrii teste

La început, testele par timp pierdut: scrii cod care verifică alt cod. Beneficiile apar însă repede, mai ales într-un proiect care crește:

  • Refactorizare fără frică. Poți rescrie o funcție știind că testele îți vor spune dacă i-ai schimbat comportamentul.
  • Feedback rapid. Un bug prins de un test în două secunde costă mult mai puțin decât unul raportat de un client.
  • Documentație vie. Un test bine numit descrie exact ce face codul și, spre deosebire de un README, nu se poate desincroniza fără să pice.
  • Design mai bun. Codul greu de testat este, de obicei, și greu de înțeles: are prea multe dependențe sau face prea multe lucruri deodată.

Pentru cineva care se reprofilează în IT, testele contează și la angajare. Un proiect de portofoliu cu teste și cu un pipeline care le rulează automat arată că știi cum se lucrează într-o echipă reală.

Piramida testelor

Cel mai cunoscut model pentru organizarea testelor este piramida: multe teste mici și rapide la bază, mai puține teste mari și lente în vârf.

NivelCe verificăVitezăCost de întreținereUnelte populare
UnitO funcție sau o clasă, izolatMilisecundeMicVitest, Jest, pytest, JUnit
IntegrationMai multe componente împreună (cod + bază de date, API + serviciu)SecundeMediupytest, Supertest, Testcontainers
End-to-end (E2E)Aplicația completă, folosită ca de un utilizator realSecunde sau minuteMarePlaywright, Cypress

Piramida nu e o lege. Unele echipe preferă „trofeul testelor” (testing trophy), cu accent pe testele de integrare, mai ales în frontend. Ideea de fond rămâne aceeași: cu cât un test e mai sus, cu atât e mai lent, mai fragil și mai scump, așa că îl folosești doar acolo unde aduce ceva ce testele de jos nu pot aduce.

Teste unitare

Un test unitar verifică o bucată mică de logică, de obicei o funcție, fără bază de date, rețea sau sistem de fișiere. Pentru că nu au dependențe externe, testele unitare sunt foarte rapide și poți rula sute dintre ele la fiecare salvare.

Structura Arrange–Act–Assert

Cel mai simplu tipar pentru un test lizibil are trei pași: pregătești datele (Arrange), apelezi codul testat (Act) și verifici rezultatul (Assert). Iată o funcție și testele ei, scrise cu Vitest:

// pret.ts
export type Produs = { pret: number; cantitate: number };

export function calculeazaTotal(produse: Produs[], cupon?: string): number {
  const subtotal = produse.reduce((suma, p) => suma + p.pret * p.cantitate, 0);
  if (cupon === "REDUCERE10") {
    return Math.round(subtotal * 0.9 * 100) / 100;
  }
  return subtotal;
}
// pret.test.ts
import { describe, it, expect } from "vitest";
import { calculeazaTotal } from "./pret";

describe("calculeazaTotal", () => {
  it("adună prețul înmulțit cu cantitatea", () => {
    // Arrange
    const produse = [
      { pret: 10, cantitate: 2 },
      { pret: 5, cantitate: 1 },
    ];
    // Act
    const total = calculeazaTotal(produse);
    // Assert
    expect(total).toBe(25);
  });

  it("aplică 10% reducere pentru un cupon valid", () => {
    expect(calculeazaTotal([{ pret: 100, cantitate: 1 }], "REDUCERE10")).toBe(90);
  });

  it("întoarce 0 pentru un coș gol", () => {
    expect(calculeazaTotal([])).toBe(0);
  });
});

Observă că al treilea test acoperă un caz-limită: coșul gol. Bug-urile apar rar pe „drumul fericit” și des la margini: valori zero, liste goale, null, șiruri foarte lungi, date calendaristice la schimbarea anului.

Ce face un test unitar bun

  • Testează un singur comportament. Dacă testul pică, numele lui ar trebui să-ți spună ce s-a stricat.
  • Este determinist. Același cod dă același rezultat de fiecare dată. Evită dependența de ora curentă, de numere aleatoare sau de ordinea de execuție.
  • Este independent. Testele nu își împart starea și pot rula în orice ordine.
  • Verifică comportamentul, nu implementarea. Testează ce întoarce funcția, nu cum ajunge acolo. Altfel, orice refactorizare îți strică testele fără motiv.

Mocks, stubs și fakes, cu măsură

Când codul tău depinde de ceva extern (un serviciu de email, un API de plăți), îl înlocuiești în test cu un „dublu” controlat. Cel mai simplu mod de a face asta este să primești dependența ca parametru (dependency injection):

// comenzi.test.ts
import { it, expect, vi } from "vitest";
import { trimiteConfirmare } from "./comenzi";

it("trimite emailul de confirmare o singură dată", async () => {
  const emailer = { trimite: vi.fn().mockResolvedValue(undefined) };

  await trimiteConfirmare({ id: 42, email: "ana@example.com" }, emailer);

  expect(emailer.trimite).toHaveBeenCalledTimes(1);
  expect(emailer.trimite).toHaveBeenCalledWith(
    "ana@example.com",
    expect.stringContaining("42"),
  );
});

Mock-urile sunt utile, dar ușor de folosit în exces. Dacă un test are zece mock-uri, probabil verifică felul în care sunt legate piesele între ele, nu comportamentul. E un semn că ai nevoie fie de un design mai simplu, fie de un test de integrare.

Teste de integrare

Testele de integrare verifică faptul că piesele funcționează împreună: codul tău cu baza de date, un endpoint HTTP cu logica din spate, două servicii care comunică. Multe bug-uri reale trăiesc exact la aceste granițe: o interogare SQL greșită, un câmp redenumit, o serializare JSON care pierde date.

Exemplu în Python cu pytest, folosind o bază de date SQLite în memorie:

# test_utilizatori.py
import sqlite3
import pytest
from app.repo import RepoUtilizatori


@pytest.fixture
def repo():
    conn = sqlite3.connect(":memory:")
    conn.execute(
        "CREATE TABLE utilizatori (id INTEGER PRIMARY KEY, email TEXT UNIQUE)"
    )
    yield RepoUtilizatori(conn)
    conn.close()


def test_salveaza_si_gaseste_utilizator(repo):
    repo.adauga("ana@example.com")
    assert repo.gaseste_dupa_email("ana@example.com") is not None


def test_email_duplicat_ridica_eroare(repo):
    repo.adauga("ana@example.com")
    with pytest.raises(sqlite3.IntegrityError):
        repo.adauga("ana@example.com")

Fixture-ul repo creează o bază de date nouă pentru fiecare test, deci testele nu se influențează între ele.

Cât de „real” trebuie să fie mediul?

SQLite în memorie e rapid, dar nu e identic cu PostgreSQL sau MySQL din producție: tipurile de date, constrângerile și unele funcții SQL diferă. Pentru teste mai fidele, biblioteca Testcontainers pornește automat un container Docker cu exact baza de date din producție și îl oprește la final. Compromisul e clar: mai multă fidelitate, în schimbul unor teste mai lente și al dependenței de Docker.

Regulă practică: folosește fake-uri pentru serviciile externe pe care nu le controlezi (plăți, email, API-uri terțe) și componente reale pentru ce e al tău (baza de date, cache-ul, propriile servicii).

Teste end-to-end

Testele E2E pornesc aplicația completă și o folosesc ca un utilizator: deschid un browser, completează formulare, apasă butoane și verifică ce apare pe ecran. Sunt singurele care îți confirmă că tot sistemul (frontend, backend, bază de date, configurare) funcționează împreună.

Un test de autentificare cu Playwright:

// login.spec.ts
import { test, expect } from "@playwright/test";

test("utilizatorul se poate autentifica", async ({ page }) => {
  await page.goto("/login");

  await page.getByLabel("Email").fill("ana@example.com");
  await page.getByLabel("Parolă").fill("parola-de-test");
  await page.getByRole("button", { name: "Intră în cont" }).click();

  await expect(page.getByRole("heading", { name: "Panoul meu" })).toBeVisible();
});

Selectorii de tipul getByRole și getByLabel caută elementele așa cum le percepe utilizatorul, nu după clase CSS. Testul rezistă astfel la schimbări de stil și, ca bonus, te împinge spre un HTML accesibil.

Problema testelor instabile (flaky)

Un test flaky trece uneori și pică alteori, fără ca codul să se fi schimbat. Testele E2E sunt cele mai expuse, din cauza animațiilor, a rețelei și a datelor partajate. Câteva reguli care ajută:

  • Nu folosi pauze fixe (sleep(2000)); așteaptă o condiție concretă. Playwright face asta automat prin auto-waiting și prin aserțiuni precum toBeVisible().
  • Fiecare test își creează propriile date și nu depinde de ce a lăsat în urmă alt test.
  • Investighează imediat un test flaky. Dacă echipa se obișnuiește să apese „re-run”, nimeni nu mai crede testele roșii.

Păstrează testele E2E pentru fluxurile critice: înregistrare, autentificare, plată, acțiunea principală a produsului. Câteva zeci de teste E2E bine alese valorează mai mult decât sute care pică aleatoriu.

Cât de mult testezi? Despre code coverage

Code coverage măsoară ce procent din linii (sau din ramuri) a fost executat de teste. E o metrică utilă ca să găsești zone netestate, dar înșelătoare ca obiectiv: poți avea acoperire de 100% cu teste care nu verifică nimic important.

În loc să urmărești un număr, pune-ți trei întrebări:

  1. Ce s-ar întâmpla dacă această parte s-ar strica în producție? Testează mai temeinic logica de business, calculele cu bani și permisiunile.
  2. Cât de des se schimbă codul? Zonele modificate des beneficiază cel mai mult de teste.
  3. Am un test pentru fiecare bug reparat? Înainte să repari un bug, scrie un test care îl reproduce. Așa nu se mai întoarce.

TDD pe scurt

Test-Driven Development inversează ordinea obișnuită: scrii întâi testul, apoi codul. Ciclul are trei pași:

  1. Red: scrii un test pentru un comportament nou; pică, pentru că funcția nu există încă.
  2. Green: scrii codul minim care face testul să treacă.
  3. Refactor: îmbunătățești codul, cu testele drept plasă de siguranță.

Nu trebuie să aplici TDD peste tot ca să profiți de el. Funcționează foarte bine pentru logică pură cu reguli clare (validări, calcule, parsere) și pentru reproducerea bug-urilor.

Testele în fluxul zilnic

Testele aduc valoare doar dacă rulează des. Un flux sănătos arată cam așa:

  • Local, în timp ce lucrezi: testele unitare rulează în watch mode (Vitest pornește implicit așa în terminalul local) sau rulezi pytest după fiecare modificare importantă.
  • La fiecare pull request: testele unitare și de integrare rulează automat în CI, iar merge-ul e blocat dacă pică.
  • Înainte și după deploy: testele E2E rulează pe un mediu de staging, iar după lansare câteva smoke tests confirmă că producția răspunde.

Greșeli frecvente

  • Teste care verifică detalii de implementare (funcții private, ordinea apelurilor interne).
  • Un singur test uriaș care verifică zece lucruri; când pică, nu știi de ce.
  • Date de test copiate peste tot, în loc de funcții helper sau fixture-uri.
  • Teste care depind de ora curentă. Injectează un „ceas” sau folosește fake timers (vi.useFakeTimers() în Vitest).
  • Teste comentate „temporar”, care rămân comentate pentru totdeauna.

Pașii următori

  • Alege un proiect mic de-al tău și adaugă 5–10 teste unitare pentru funcțiile cu cea mai multă logică.
  • Scrie un test de integrare pentru un endpoint sau pentru stratul de acces la date.
  • Adaugă un singur test E2E pentru fluxul principal al aplicației.
  • Parcurge roadmap-ul QA pentru o imagine completă a meseriei de testare și roadmap-ul JavaScript dacă vrei să aprofundezi limbajul din exemple.
  • Când te blochezi, întreabă tutorul AI. De exemplu, cere-i să-ți propună cazuri-limită pentru o funcție de-a ta.