Spring Boot: teste automate pentru API-ul REST
Testează un API Spring Boot cu MockMvc: creare, citire, validare, 404 și lipsa scrierilor la erori. Înțelege izolarea datelor și limitele testelor.
În acest material
Un API care răspunde corect o singură dată nu este încă verificat suficient. Avem nevoie de teste care detectează date invalide, resurse inexistente și efecte secundare nedorite. În această lecție testăm proiectul real, cu controller, validare, serviciu și repository.
Punctul de plecare
Continuă proiectul după JPA și tranzacții. Folosim Java 21, Spring Boot 4.1.1 și starterul spring-boot-starter-webmvc-test, deja prezent în POM. Importul pentru AutoConfigureMockMvc din această serie este cel pentru Boot 4, nu vechiul pachet din unele exemple Boot 3.
Testele folosesc H2 în memorie și nu contactează o bază externă. @SpringBootTest încarcă aplicația, iar MockMvc trimite cereri prin infrastructura MVC fără a porni un server HTTP pe un port real. Aceasta verifică integrarea internă, dar nu înlocuiește o probă reală de rețea sau un test pe motorul de baze de date ales pentru producție.
Testează rezultate și efecte
Creează src/test/java/ro/algo25/tasks/TaskApiTest.java:
package ro.algo25.tasks;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class TaskApiTest {
@Autowired MockMvc mvc;
@Autowired TaskRepository repository;
@BeforeEach
void clearData() {
repository.deleteAll();
}
@Test
void createsAndReadsUsingLocation() throws Exception {
var created = mvc.perform(post("/api/tasks").contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\" Invata Spring \"}"))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.title").value("Invata Spring"))
.andExpect(jsonPath("$.completed").value(false)).andReturn();
mvc.perform(get(created.getResponse().getHeader("Location")))
.andExpect(status().isOk()).andExpect(jsonPath("$.title").value("Invata Spring"));
assertEquals(1, repository.count());
}
@Test
void rejectsBlankWithoutSaving() throws Exception {
mvc.perform(post("/api/tasks").contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\" \"}"))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.status").value(400));
assertEquals(0, repository.count());
}
@Test
void rejectsNullOversizedAndMalformedBodies() throws Exception {
for (String body : new String[] {"{}", "{\"title\":null}",
"{\"title\":\"" + "a".repeat(81) + "\"}", "{"}) {
mvc.perform(post("/api/tasks").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest());
}
assertEquals(0, repository.count());
}
@Test
void missingTaskIs404AndEmptyListIs200() throws Exception {
mvc.perform(get("/api/tasks/999999"))
.andExpect(status().isNotFound()).andExpect(jsonPath("$.status").value(404));
mvc.perform(get("/api/tasks"))
.andExpect(status().isOk()).andExpect(content().json("[]"));
}
@Test
void acceptsExactly80AndKeepsDuplicateTitlesSeparate() throws Exception {
String title = "a".repeat(80);
String firstLocation = mvc.perform(post("/api/tasks").contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"" + title + "\"}"))
.andExpect(status().isCreated()).andReturn().getResponse().getHeader("Location");
String secondLocation = mvc.perform(post("/api/tasks").contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"" + title + "\"}"))
.andExpect(status().isCreated()).andReturn().getResponse().getHeader("Location");
org.junit.jupiter.api.Assertions.assertNotEquals(firstLocation, secondLocation);
assertEquals(2, repository.count());
}
@Test
void exposesOnlyBasicHealth() throws Exception {
mvc.perform(get("/actuator/health"))
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP"))
.andExpect(jsonPath("$.components").doesNotExist());
mvc.perform(get("/actuator/env")).andExpect(status().isNotFound());
}
}
Rulează ./mvnw test, sau mvnw.cmd test pe Windows, din directorul proiectului. Comanda trebuie să se încheie cu succes și fără teste eșuate. Nu interpreta simpla apariție a bannerului Spring ca rezultat al suitei: citește totalul testelor, eșecurile și codul de ieșire.
De ce aceste cazuri sunt diferite
Testul de creare verifică statusul 201, antetul Location, textul normalizat și citirea ulterioară. El nu presupune un anumit ID. Generatorul bazei poate continua numerotarea chiar dacă tabelul este golit între teste; așteptarea rigidă a ID-ului unu ar lega testul de un detaliu inutil.
Testele de validare verifică și numărul de înregistrări. Astfel detectăm cazul periculos în care endpointul răspunde cu eroare, dar a salvat totuși date. Verificarea pentru un JSON incomplet testează parsarea, iar un titlu prea lung testează validarea; cele două erori nu sunt același mecanism.
Lista goală și resursa absentă au rezultate diferite: 200 cu [], respectiv 404. Testul pentru Actuator verifică starea de sănătate fără detalii interne și confirmă că /actuator/env nu este expus. Lecția următoare va face explicită configurația acestor endpointuri.
Izolarea datelor de test
Înaintea fiecărui test ștergem sarcinile din baza locală de test. Aceasta este o operație limitată proiectului didactic cu H2, nu un model de comandă care trebuie rulat pe o bază reală. Numele bazei și proprietățile folosite la teste trebuie controlate.
O altă strategie poate folosi rollback tranzacțional, dar trebuie să înțelegi granițele: într-un test cu server pe port real, clientul și serverul pot lucra în fire și tranzacții diferite. Tranzacția testului nu anulează automat orice scriere făcută de server. Alege metoda de curățare după tipul testului.
Completează piramida de verificare
O regulă pură de normalizare poate fi testată fără pornirea Spring. Un test focalizat pe MVC poate verifica numai controllerul și colaboratorii necesari. Suita de aici încarcă întregul context pentru a urmări traseul complet în aplicație. Niciun nivel nu este „cel mai bun” pentru toate întrebările.
Adaugă teste când protejează un comportament important, nu doar ca să reproduci linie cu linie implementarea. Un test care știe exact ordinea apelurilor interne poate împiedica o refactorizare corectă, în timp ce ratează contractul public. Începe de la ce trebuie să observe clientul și ce trebuie să rămână adevărat în date.
Exersează
- Schimbă intenționat limita titlului și verifică un caz cu exact 80 de litere. Testul de graniță trebuie să detecteze schimbarea contractului.
- Elimină temporar
@Valid. Cazurile de titlu invalid trebuie să evidențieze regresia. Reintrodu adnotarea după experiment. - Adaugă un test pentru două sarcini cu același titlu. Sunt permise și trebuie să aibă ID-uri diferite; o presupunere de unicitate a titlului ar fi o altă cerință.
Verifică înainte să continui
Poți continua dacă explici ce verifică fiecare test, ce infrastructură pornește și de ce datele sunt izolate. Urmează configurare, profile și Actuator, apoi verificările de nivel senior din parcurs.
Întrebări frecvente
MockMvc deschide un port HTTP real?
Nu în această configurație. Cererile trec prin infrastructura MVC de test, în proces.
Un test verde cu H2 dovedește compatibilitatea cu PostgreSQL?
Nu. Pentru acea afirmație trebuie teste pe PostgreSQL, inclusiv comportamente SQL și de tranzacție relevante.
Trebuie să verific ID-ul exact?
Nu aici. Verifică existența lui și folosește Location pentru citire. Contractul nu promite o anumită valoare numerică inițială.
De ce verificăm count după un 400?
Ca să confirmăm absența scrierii, nu doar răspunsul HTTP. Sunt două proprietăți diferite.
Toate testele trebuie să folosească SpringBootTest?
Nu. Regulile independente pot avea teste rapide fără context, iar unele integrări pot folosi teste mai focalizate.
Surse
Testarea aplicațiilor Spring Boot și Spring MVC Test, consultate la 13 octombrie 2026.