Testes de contrato para Spring Boot e Angular: Identifique discrepâncias entre front-end e back-end antes da produção.

Seu serviço Angular lê courseNametodas as respostas. Na última terça-feira, o backend renomeou esse campo para name. Ambos os projetos compilam. Ambos os conjuntos de testes passam. Em produção, a tabela fica cheia de campos em branco.
Nada em nenhum dos códigos-fonte estava errado isoladamente. O controlador Spring retorna um DTO perfeitamente válido. O Angular CoursesServicemapeia uma interface TypeScript perfeitamente válida. O bug reside no espaço entre eles, no acordo que ambos os lados acreditam compartilhar e que nenhum teste verifica de fato. O compilador não consegue enxergar através da barreira da rede. Seus testes unitários simulam isso. Sua interface TypeScript é uma transcrição aproximada de um formato JSON que o backend pode alterar livremente sem avisar ninguém.
Esta é a única brecha que todas as técnicas em minhas postagens recentes deliberadamente não conseguem alcançar. Na postagem sobre testes de mutação , o PIT protege o comportamento do seu código. Na postagem sobre testes de arquitetura , o ArchUnit protege sua forma — mas o faz lendo o bytecode compilado de uma única base de código. O ArchUnit pode provar que seu código CourseControllernunca retorna uma entidade JPA. Ele não tem ideia do que o cliente Angular do outro lado desse endpoint realmente espera, porque esse cliente não está em seu classpath. Nenhum dos sensores consegue ver o contrato entre dois processos implantados separadamente.
O teste de contrato é o sensor dessa falha. Em vez de esperar que o produtor e o consumidor permaneçam sincronizados, você captura o acordo entre eles como um artefato executável — um contrato — e verifica ambos os lados em relação a ele a cada build. O frontend prova que solicita o que pensa que solicita. O backend prova que entrega exatamente isso. No momento em que alguém renomeia courseNameum arquivo nameem um dos lados, um teste falha no outro, antes que qualquer um dos lados seja implantado.
Esta é a mesma estratégia que sempre uso: transformar uma suposição que existe apenas na cabeça de alguém em uma verificação determinística que a compilação se recusa a ignorar. Este artigo explica o que o teste de contrato realmente verifica, as vantagens e desvantagens reais entre as duas ferramentas dominantes na JVM (Spring Cloud Contract e Pact) e uma configuração completa para um consumidor Angular e um produtor Spring Boot.
Quer o código? Todos os exemplos neste post são direcionados ao projeto CRUD full-stack que uso neste blog e nos meus vídeos: loiane/crud-angular-spring
Neste post, abordaremos:
- Por que a fronteira entre frontend e backend é o ponto cego que nenhum teste em processo consegue cobrir.
- O que os testes de contrato realmente verificam e o vocabulário necessário: consumidor, produtor, contrato, estado fornecedor
- Spring Cloud Contract vs Pact: qual deles se encaixa melhor na transição entre Angular e Spring (com uma recomendação sincera)?
- Escrevendo um contrato de consumidor no lado do Angular
- Verificar se o contrato do produtor Spring Boot, incluindo os estados do fornecedor, está correto.
- Lendo um contrato quebrado: a
courseNamemudança de nome, capturada deterministicamente - Compartilhamento de contratos entre recompradores com um Pact Broker e o
can-i-deploygateway. - O problema que isso identifica é exatamente por que o mesmo agente de IA redigindo ambos os lados do contrato, com exemplos de instruções.
- Executando verificação de contrato no GitHub Actions
- Um caminho de adoção que não exige uma reformulação radical.
Por que a fronteira é o ponto cego
Todos os testes que você já executa residem dentro de um único processo. O Spring @WebMvcTestinicializa o controlador e simula o serviço. O Angular HttpTestingControllerintercepta a requisição e retorna um fixture que você escreveu. Ambos são úteis, e ambos compartilham a mesma limitação fatal para este problema: o que está do outro lado da rede é um substituto que você controla, não a contraparte real.
Essa é a lacuna. Um mock só é tão preciso quanto sua memória do que o outro lado faz. No dia em que o backend muda sua resposta, seu fixture do Angular continua retornando o formato antigo, seu teste continua passando e seu conjunto de testes agora mente ativamente para você. Ele reporta resultados verdes justamente porque está fazendo asserções em relação a uma versão da realidade que não existe mais.
Considere o endpoint central do aplicativo CRUD: GET /api/coursesretorna uma lista paginada de cursos. O produtor Spring serializa um `List<List<Curso>>` CourseDTO. O consumidor Angular o desserializa em uma Courseinterface. Eis o acordo completo, e nada o impõe:
// Producer: crud-spring — what Spring actually returns
public record CourseDTO(
@JsonProperty("_id") Long id,
String name,
String category,
List<LessonDTO> lessons
) {}// Consumer: crud-angular — what Angular expects to receive
export interface Course {
_id: number;
name: string;
category: string;
lessons: Lesson[];
}Essas duas declarações deveriam descrever o mesmo JSON. Observe o ` :`: o campo _iddo registro é serializado com um nome diferente devido a uma anotação, que é exatamente o tipo de detalhe que uma interface TypeScript mantida manualmente deixa passar despercebido. Nada em nenhuma das compilações verifica se elas ainda estão corretas. Renomeie para `no` no registro, e o lado Java compila, os testes Java passam e o lado Angular não percebe nada até que um usuário carregue a página. A interface TypeScript não é um contrato. É um comentário que por acaso foi escrito em TypeScript.id@JsonPropertynametitle
O teste de contrato elimina essa lacuna ao tornar o próprio acordo o artefato compartilhado. Em vez de duas transcrições independentes que divergem, existe um único contrato que ambas as partes verificam. Quando há discordância, uma compilação é considerada vermelha em vez de um usuário registrar um bug.
O que os testes de contrato realmente verificam
O vocabulário é pequeno, e esclarecê-lo logo de início facilita tudo o que vem depois.
| Prazo | O que significa |
|---|---|
| Consumidor | O lado que chama a API. Neste caso, o aplicativo Angular. |
| Produtor/Fornecedor | O lado que serve a API. Aqui, o backend Spring Boot. |
| Contrato | Uma descrição executável de uma solicitação feita pelo consumidor e da resposta esperada. |
| Estado do provedor | Uma pré-condição específica que o produtor define para que a resposta no contrato seja reproduzível (por exemplo, “existem três cursos”). |
| Verificação | Reproduzir a solicitação do contrato contra o produtor real e afirmar que a resposta real corresponde às condições estabelecidas é o correto. |
A ideia fundamental é que um contrato não é um documento de esquema que você escreve uma vez e esquece. Ele é gerado a partir de uma interação real e, em seguida, reproduzido no produtor real. O consumidor diz: “Quando eu enviar GET /api/courses, espero um código 200 com um array JSON onde cada item tem um valor numérico _ide uma string name“. O teste do produtor pega essa expectativa exata, envia a solicitação para o controlador em execução e verifica se a resposta real a satisfaz.
Disso decorrem duas propriedades que um arquivo OpenAPI escrito manualmente jamais poderá fornecer:
- O consumidor apenas declara o que utiliza. Se o backend adicionar um novo
createdAtcampo, o contrato não é quebrado, porque o aplicativo Angular nunca o solicitou. O teste de contrato verifica a compatibilidade, não a identidade. É isso que permite que os dois lados evoluam independentemente, em vez de em sincronia. - O produtor é verificado em relação ao comportamento real. O contrato é reproduzido em relação ao controlador e serializador reais, de modo que uma incompatibilidade entre o registro e o JSON — um campo renomeado, uma mudança de tipo, um valor nulo onde um valor foi prometido — causa falha na própria compilação do produtor.
Esta é uma função de adequação exatamente no sentido descrito na postagem sobre engenharia de chicotes elétricos : uma verificação automatizada e objetiva de que uma característica arquitetônica específica — neste caso, a compatibilidade entre serviços — ainda se mantém. Enquanto o ArchUnit é um sensor da estrutura de uma base de código, o teste de contrato é um sensor da estrutura do acordo entre duas.
Contrato Spring Cloud vs Pacto
Existem duas ferramentas robustas para teste de contratos na JVM, e elas diferem em um aspecto importante para esta decisão. A escolha certa se resume basicamente a uma questão: quem é o proprietário do contrato e em qual linguagem o consumidor foi escrito?
O Spring Cloud Contract é orientado ao produtor. O autor do produtor escreve o contrato em uma DSL Groovy ou YAML, e o plugin gera duas coisas a partir dele: testes de verificação que comprovam que o produtor honra o contrato e um stub WireMock que o consumidor utiliza. A integração é perfeita quando ambos os lados estão na JVM — o stub é distribuído como um artefato Maven, e um consumidor Spring o utiliza com o Stub Runner, obtendo um backend simulado realista gratuitamente.
O Pact é orientado ao consumidor. O autor do consumidor escreve um teste na linguagem do próprio consumidor que descreve as interações necessárias. A execução desse teste gera um arquivo Pact (JSON) que captura cada par de requisição/resposta. O produtor então reproduz esse arquivo Pact no serviço real para verificá-lo. O Pact é poliglota por natureza: existe uma biblioteca JavaScript/TypeScript de primeira classe para o consumidor e um verificador JVM para o produtor.
Eis aqui a comparação honesta para a decisão em questão:
| Dimensão | Contrato Spring Cloud | Pacto |
|---|---|---|
| Direção do contrato | Orientado pelo produtor | orientado para o consumidor |
| Quem redige o contrato? | Produtor, em uma DSL Groovy/YAML | Consumidor, em sua própria linguagem |
| Adequação da linguagem do consumidor | Prioridade à JVM | Poliglota (TypeScript, Java, Go, …) |
| Como o consumidor verifica | Executa em um stub WireMock gerado. | Executa contra um servidor simulado do Pact. |
| Como o produtor verifica | Testes gerados por plugins a partir da DSL | Reproduz o arquivo do contrato do consumidor |
| Partilha de contratos | Stubs como artefatos Maven ou Git | Corretor de Pactos, ou arquivos de pactos comprometidos |
| ponto ideal | Serviço JVM ↔ Serviço JVM | Multilíngue, especialmente SPA ↔ API |
Para uma comunicação direta entre JVMs — dois serviços Spring Boot se comunicando entre si — eu opto pelo Spring Cloud Contract sem hesitar. Ele mantém tudo em um único ecossistema, e a integração com o Stub Runner é difícil de superar.
Neste projeto, o consumidor é o Angular. O contrato é mais significativo quando escrito pela parte que realmente depende da estrutura, na linguagem em que essa parte foi escrita, de forma que o TypeScript que consome nameseja o mesmo código que declara sua necessidade name. É exatamente isso que o Pact orientado ao consumidor oferece, e é por isso que o restante deste artigo usa o Pact : o aplicativo Angular escreve o contrato e o backend Spring Boot comprova que pode cumpri-lo. Se sua arquitetura for toda baseada em serviços Spring, basta substituir mentalmente o Spring Cloud Contract e a função de intermediário será idêntica — apenas a mecânica muda.
Escrevendo o contrato do consumidor em Angular
O teste do consumidor descreve uma interação: a requisição que o Angular CoursesServicefaz e a resposta que ele precisa receber. O Pact inicia um servidor mock local, seu código o chama e, se a chamada for bem-sucedida, o Pact grava um arquivo de pacto descrevendo a troca de informações.
Uma observação prática antes do código: o Pact JS é executado no Node.js, não no navegador. Portanto, esses testes de contrato ficam vinculados ao seu projeto Angular, mas são executados como testes do Node.js, separadamente dos testes unitários do Angular. Neste projeto, eles são executados pelo Vitest por meio de um ambiente dedicado vitest.pact.config.tscom ` –node-tests` environment: 'node', tsconfig.spec.jsonexcluindo o ambiente `node-tests`, src/contract/**de forma que ng testo `node-tests` nunca os execute. Trata-se de um conjunto de testes distinto e rápido, cuja única função é gerar o arquivo `pact.js`. Adicione a dependência ao crud-angularprojeto:
npm install --save-dev @pact-foundation/pactConecte um test:pactscript e a configuração do Vitest para que o conjunto de testes seja executado automaticamente:
// crud-angular/package.json
"scripts": {
"test:pact": "vitest run --config vitest.pact.config.ts"
}// crud-angular/vitest.pact.config.ts
import { defineConfig } from 'vitest/config';
// Pact consumer tests run in Node (they spin up a native mock server) and are
// intentionally kept separate from the Angular/jsdom unit tests.
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/contract/**/*.pact.spec.ts'],
},
});Em seguida, escreva um teste que exercite o formato HTTP real do qual seu serviço depende. Os matchers eachLike`type` integer, `structure` e string`value` são essenciais: eles verificam o tipo e a estrutura de cada campo, não os valores exatos, de modo que o contrato permaneça estável mesmo quando os dados reais mudarem.
// crud-angular/src/contract/course.consumer.pact.spec.ts
import path from 'node:path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { eachLike, integer, string } = MatchersV3;
const provider = new PactV3({
consumer: 'crud-angular',
provider: 'crud-spring',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('CoursesService contract', () => {
it('GET /api/courses returns a page of courses', () => {
provider
.given('three courses exist')
.uponReceiving('a request for the first page of courses')
.withRequest({
method: 'GET',
path: '/api/courses',
query: { page: '0', pageSize: '10' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
totalElements: integer(3),
totalPages: integer(1),
courses: eachLike({
_id: integer(1),
name: string('Angular'),
category: string('Front-end'),
}),
},
});
return provider.executeTest(async (mockServer) => {
// In the Angular app this call goes through CoursesService.list();
// here we hit the Pact mock server directly to record the interaction.
const response = await fetch(
`${mockServer.url}/api/courses?page=0&pageSize=10`
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.courses[0]._id).toBeDefined();
expect(body.courses[0].name).toBeDefined();
expect(body.courses[0].category).toBeDefined();
});
});
});Leia o que este teste realmente demonstra. Ele afirma que o aplicativo Angular envia GET /api/coursesparâmetros de consulta pagee pageSizeprecisa de uma resposta 200 contendo totalElementsnúmeros totalPagese um coursesarray onde cada elemento possui um inteiro _id, uma string namee uma string category. Não menciona nada sobre campos que não são utilizados. Essa restrição é justamente o objetivo: o contrato é o mínimo necessário para o consumidor, permitindo que o produtor adicione qualquer coisa sem quebrá-lo.
Executar este teste produz crud-angular/pacts/crud-angular-crud-spring.jsonum arquivo . Esse arquivo é o contrato. Ele é gerado automaticamente, não escrito manualmente, o que significa que nunca poderá divergir do que o código do consumidor realmente faz — se você alterar a solicitação, você altera o contrato.
Verificação do contrato do produtor de molas
Agora, a outra metade. O produtor pega o arquivo de pacto gerado pelo consumidor e reproduz cada interação nele, comparando-a com o controlador real, verificando se a resposta real corresponde. Se corresponder, os dois lados são compatíveis. Caso contrário, a versão do produtor falha.
Adicione a dependência do provedor JVM Pact a crud-spring:
<dependency>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>junit5spring</artifactId>
<version>4.6.17</version>
<scope>test</scope>
</dependency>Em seguida, escreva um teste de verificação. Ele direciona o Pact para o arquivo de pacto, inicia o aplicativo em uma porta aleatória e reproduz cada interação via HTTP real. @StateÉ nesse método que os estados do provedor mostram sua importância: o contrato do consumidor dizia given('three courses exist'), então é aqui que o produtor torna essa pré-condição verdadeira antes da execução da requisição.
// crud-spring/src/test/java/com/loiane/contract/CourseContractVerificationTest.java
package com.loiane.contract;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import com.loiane.course.CourseService;
import com.loiane.course.dto.CourseDTO;
import com.loiane.course.dto.CoursePageDTO;
import au.com.dius.pact.provider.junit5.HttpTestTarget;
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.State;
import au.com.dius.pact.provider.junitsupport.loader.PactFolder;
@Provider("crud-spring")
@PactFolder("../crud-angular/pacts")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CourseContractVerificationTest {
@LocalServerPort
private int port;
@MockitoBean
private CourseService courseService;
@BeforeEach
void setTarget(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
@State("three courses exist")
void threeCoursesExist() {
when(courseService.findAll(0, 10)).thenReturn(
new CoursePageDTO(List.of(
new CourseDTO(1L, "Angular", "Front-end", List.of()),
new CourseDTO(2L, "Spring Boot", "Back-end", List.of()),
new CourseDTO(3L, "Java", "Back-end", List.of())
), 3, 1)
);
}
}Por que não @WebMvcTest?MockMvcTestTarget
A maioria dos tutoriais do Pact JVM — e uma versão anterior deste post — implementa a verificação com @WebMvcTesto `pact.protocol` MockMvcTestTarget, que reproduz as interações na camada MockMvc sem nunca abrir um socket. Isso não funciona no Spring Boot 4. O Pact JVM 4.6.17 é compilado com o Spring Framework 6 e MockMvcTestTargetchama MockHttpServletRequestBuilder.headers(...)o `pact.protocol` com uma assinatura que não existe mais no Spring Framework 7, então a verificação falha com um erro NoSuchMethodError. Além disso, @WebMvcTesto próprio `pact.protocol` foi movido para um novo pacote no Boot 4. Inicializar a aplicação real com `pact.protocol` @SpringBootTest(webEnvironment = RANDOM_PORT)e apontar o Pact para ela com ` HttpTestTargetpact.protocol` contorna completamente a incompatibilidade — e verifica a resposta HTTP verdadeira, serialização e tudo mais, o que é mais próximo do que você realmente deseja. Este é o equivalente, em testes de contrato, às armadilhas do JUnit 5 e Surefire mencionadas nos posts sobre PIT e ArchUnit : a configuração que todos os tutoriais mostram é a que falha silenciosamente na pilha atual.
Vale a pena rastrear o fluxo de ponta a ponta, pois é aqui que o teste de contrato deixa de ser abstrato. O consumidor disse: “quando existirem três cursos, GET /api/coursesme retorne uma página com três cursos”. O teste do produtor configura exatamente esse estado e, em seguida, o Pact envia uma solicitação HTTP real para o servidor inicializado, através do CourseControllerserializador Jackson real, e verifica o JSON real em relação às expectativas do consumidor. O serviço é simulado para que os dados sejam determinísticos, mas todo o caminho de serialização — nomes de campos, tipos, estrutura — é exercitado de verdade. Se o registro for idserializado como `<nome_do_registro>` _ide o Pact esperar _id`<nome_do_registro>`, o teste é aprovado. Se alguém renomear um campo, o teste falha exatamente aqui, na compilação do produtor, ao nomear o campo exato.
Lendo um contrato rompido
Essa é a recompensa, então vale a pena ver exatamente o que acontece quando os dois lados divergem. Voltando ao cenário inicial: alguém renomeia namepara titleno produtor CourseDTO, atualiza o código Java e os testes do Spring continuam passando porque nada no projeto Spring sabe que o aplicativo Angular depende de name.
Na próxima vez que o produtor executar a verificação de contrato, o Pact reproduzirá o pacto do consumidor — que ainda espera name— em relação à nova resposta e falhará com uma mensagem como esta:
Failures:
1) Verifying a pact between crud-angular and crud-spring
[GET /api/courses] a request for the first page of courses
has a matching body
$.courses[0] -> Actual map is missing the following keys: name
Essa mensagem representa toda a proposta de valor. Ela não diz “algo mudou”. Ela diz: o campo que o aplicativo Angular lê courses[0].namenão está mais na resposta. Quem renomeou o campo descobre, no momento da renomeação, que um consumidor depende do nome antigo, em vez de um usuário descobrir isso em produção três implantações depois. A situação que antes era um incidente de produção se torna um simples visto em um pull request.
E repare em qual build detectou o problema: o do produtor. A equipe que fez a alteração é a equipe que vê a falha. Essa é a principal razão para executar a verificação do produtor no próprio pipeline do backend, e não em algum ponto posterior.
Compartilhamento de contratos entre provedores de recompra
No projeto CRUD, o frontend e o backend residem no mesmo repositório, permitindo que o produtor leia o arquivo de pacto diretamente de ../crud-angular/pactslá @PactFolder, como mostrado acima. Essa é a configuração mais simples possível e perfeitamente adequada para começar.
Em sistemas reais, geralmente o consumidor e o produtor são separados em repositórios distintos, o que impede o uso de um caminho de arquivo compartilhado. É aí que o Pact Broker entra em ação. O broker é um serviço que armazena pactos e resultados de verificação, respondendo à pergunta crucial no momento da implantação: a versão que estou prestes a distribuir é compatível com a versão já em execução no outro lado?
O consumidor publica seu pacto no broker após gerá-lo. O produtor obtém o pacto mais recente do broker em vez de uma pasta, trocando o carregador:
@Provider("crud-spring")
@PactBroker(url = "https://your-broker.example.com")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CourseContractVerificationTest {
// identical body — only the pact source changed
}O broker também desbloqueia o mecanismo de implantação que torna o teste de contratos seguro em um pipeline: can-i-deployAntes de uma implantação, você consulta o broker para verificar se esta versão específica possui um contrato verificado e compatível com todos os ambientes com os quais precisa se comunicar:
pact-broker can-i-deploy \
--pacticipant crud-angular \
--version "$GIT_SHA" \
--to-environment productionSe a resposta for não — o backend ainda não verificou o contrato mais recente do frontend — a implantação é interrompida. Essa é a mesma filosofia de “porta de mão única” do ArchUnit freeze: o pipeline se recusa a prosseguir a menos que o sensor esteja satisfeito. Você não precisa do broker no primeiro dia, mas no momento em que seu consumidor e produtor implantam de forma independente, ele se torna o componente que transforma uma pilha de arquivos de pacto em uma verdadeira rede de segurança.
Por que isso importa ainda mais quando uma IA escreve os dois lados?
Venho construindo isso ao longo de toda a série sobre desenvolvimento orientado a especificações , e o teste de contrato é onde a junção entre frontend e backend finalmente é analisada.
Quando um humano constrói o endpoint do backend em um sprint e a equipe de frontend o implementa no seguinte, o contrato é negociado por meio de conversas, um tópico no Slack ou um arquivo OpenAPI mantido manualmente por alguém. Ele se desvia, mas lentamente, e um humano de cada lado geralmente percebe. Quando um agente de IA cria o controlador Spring e o serviço Angular em uma única sessão, o agente está otimizando para uma coisa: fazer o recurso funcionar agora, nesta conversa. Ele renomeará alegremente um campo no DTO e esquecerá de atualizar o consumidor que o lê — ou, pior, atualizará o consumidor para ler um campo que o DTO não envia mais e nem perceberá, porque as duas edições ocorreram com minutos de diferença e nenhuma delas quebrou um teste que o agente pudesse ver.
Este é exatamente o modo de falha que a postagem do ArchUnit sinalizou e não conseguiu solucionar completamente. O ArchUnit pode garantir que o controlador retorne um DTO e nunca uma entidade JPA — mas não pode garantir que o DTO retornado pelo backend seja o DTO esperado pelo cliente Angular, porque o cliente Angular não está em seu classpath. O teste de contrato é o sensor que abrange ambos os classpaths. É a única coisa em toda esta série que verifica a concordância entre os dois artefatos produzidos pelo agente, em vez dos detalhes internos de cada um deles.
A estrutura é a mesma de sempre. Uma linha copilot-instructions.mdque diz “mantenha as interfaces do Angular sincronizadas com os DTOs do Spring” é um guia inferencial de feedforward : ela influencia o modelo, e o modelo pode ignorá-la sob pressão. Um teste de contrato é um sensor de feedback computacional : ele falha na compilação quando as duas partes discordam, seja a discordância escrita por uma pessoa, um par ou um prompt.
Se você estiver executando esse trabalho com um agente de IA, o aproveitamento do recurso será maior quando o agente gerar os testes de contrato como parte integrante da funcionalidade, e não como uma solução posterior. Exemplos de prompts que eu uso:
We are adding a GET /api/courses endpoint to the Spring Boot backend
(crud-spring) and consuming it in the Angular app (crud-angular).
First, write the Pact consumer test in crud-angular that captures exactly
the fields the Angular CoursesService reads from the response — no more.
Use MatchersV3 type matchers (integer, string, eachLike), not literal
values. Generate the pact into crud-angular/pacts.
Then write the Pact JVM provider verification test in crud-spring that
loads that pact with @PactFolder and verifies it against a real server
booted with @SpringBootTest(RANDOM_PORT) and HttpTestTarget (MockMvcTestTarget
is binary-incompatible with Spring Boot 4). Add a @State method for every
provider state the consumer test declares.E uma instrução subsequente que transforma tudo em uma etapa que o agente deve cumprir:
Treat the contract as the source of truth. If you change any field on
CourseDTO, update the Angular interface AND the consumer pact test in the
same change, then run the producer verification and show me it passes.
Do not consider the task done until `mvn -Dtest=CourseContractVerificationTest
test` is green.Essa segunda mensagem é a importante. Ela transforma “por favor, mantenha-os sincronizados” de uma mera expectativa em um critério de aceitação que o agente precisa comprovar, usando o mesmo teste que o pipeline de CI executará.
Executando a verificação de contrato no GitHub Actions
Como o teste do consumidor é um teste Node e o teste do produtor é um teste JUnit, ambos se encaixam em pipelines que você quase certamente já possui. O job do consumidor executa o teste que gera o pacto e o publica; o job do produtor o verifica. Em uma configuração de repositório único, como o projeto CRUD, a sequência é simples:
contract-consumer:
name: Contract - Generate Consumer Pact (Angular)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
cache-dependency-path: crud-angular/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: crud-angular
- name: Generate consumer pact
run: npm run test:pact
working-directory: crud-angular
- name: Upload pact artifact
uses: actions/upload-artifact@v7
with:
name: pacts
path: crud-angular/pacts
contract-provider:
name: Contract - Verify Provider Against Pact (Spring)
runs-on: ubuntu-latest
needs: contract-consumer
steps:
- uses: actions/checkout@v7
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
java-version: '25'
distribution: 'temurin'
cache: maven
- name: Download pact artifact
uses: actions/download-artifact@v7
with:
name: pacts
path: crud-angular/pacts
- name: Verify pact
run: mvn -B test -Dtest=CourseContractVerificationTest --file crud-spring/pom.xml
O provedor executa needsa tarefa do consumidor, portanto a verificação é sempre executada com base no contrato recém-gerado. Um contrato quebrado falha no teste JUnit, o que resulta na falha da tarefa, o que torna a solicitação de pull request vermelha — o mesmo mecanismo de controle de acesso que todos os outros testes na compilação, sem necessidade de infraestrutura especial. Ao migrar para repositórios separados, isso se torna uma etapa de publicação no broker no lado do consumidor e uma verificação de pull do broker no lado do produtor, integradas can-i-deployantes de cada implantação.
Garantir a Confiabilidade dos Contratos
Um conjunto de contratos se deteriora da mesma forma que qualquer conjunto de testes — por meio de ruído e negligência. Algumas práticas o mantêm íntegro:
Afirme apenas o que o consumidor usa. A maior tentação é fazer com que o contrato espelhe a resposta completa. Resista a ela. Cada campo que você fixa e que o aplicativo Angular não lê de fato é um acoplamento falso que será quebrado por uma alteração inofensiva no backend e treinará a equipe a ignorar falhas. O contrato representa as necessidades do consumidor, não a saída do produtor .
Use correspondências de tipo, não valores literais. string('Angular') Nos exemplos acima, a correspondência é com qualquer string, não com o literal “Angular”. Um contrato cheio de valores exatos é, na verdade, um teste de ponta a ponta disfarçado de contrato, e falhará a cada alteração de dados. Compare estrutura e tipo; deixe os valores para seus outros testes.
Torne os estados do provedor baratos e explícitos. Cada given(...)estado no consumidor precisa de um correspondente @Stateno produtor. Mantenha esses métodos de estado pequenos — um mock ou um fixture mínimo — e nomeie-os de acordo com a pré-condição, não com a implementação. Eles são o ponto de junção onde o produtor torna o contrato reproduzível e devem ser lidos como a frase que o consumidor escreveu.
Verifique na versão do produtor, no repositório do produtor. O valor dos testes de contrato se perde se a equipe que altera o campo não for a mesma que vê a falha. Execute a verificação do produtor onde as alterações no backend são feitas, para que o feedback chegue à pessoa que pode agir sobre ele.
Exclua os testes de contrato das execuções de mutação. Se você também executar testes de mutação PIT , exclua o pacote de contrato ( com.loiane.contract.*) dele. Assim como as regras do ArchUnit, a verificação de contrato reproduz um pacto externo em vez de executar sua própria lógica, portanto, não elimina nenhum mutante e apenas torna a execução da mutação mais lenta. Cada sensor tem sua própria função; não faça com que um avalie o outro.
Conclusão
Um arquivo OpenAPI informa qual era a intenção da API. Suas interfaces TypeScript informam o que o frontend espera receber. Nenhum dos dois é verificado em relação ao outro, e é por isso que a fronteira entre um backend Spring e um frontend Angular é o único ponto em que uma versão bem-sucedida ainda pode apresentar uma funcionalidade com defeito. O teste de contrato elimina essa lacuna, tornando o próprio contrato executável, gerado a partir de código real do consumidor e reproduzido contra o produtor real, de modo que a discrepância se torna um sinal de alerta em vez de um chamado de suporte.
Um caminho razoável para a adoção:
- Escolha o endpoint mais utilizado e de maior demanda. Escreva um teste de consumidor Pact no lado do Angular que capture apenas os campos que ele utiliza. Gere o pacto.
- Adicione a dependência do provedor JVM Pact e um teste de verificação no lado do Spring. Carregue o Pact com `npm install pact`
@PactFolder, adicione um `npm run dev`@Statepara cada estado do provedor e observe se o teste é aprovado. - Quebre-o de propósito. Renomeie um campo no DTO e confirme se a verificação do produtor fica vermelha com uma diferença em nível de campo. Essa falha representa o valor total; veja-a apenas uma vez.
- Integre ambos os testes à CI como tarefas separadas, com a tarefa produtora dependendo do artefato de pacto da tarefa consumidora.
- Quando o consumidor e o produtor se separam em repositórios distintos, introduza um Pact Broker e implemente-os com um gate
can-i-deploy. - Expanda um ponto de extremidade por vez. Os testes de contrato recompensam a adoção incremental — cada ponto de extremidade coberto representa mais um limite que a compilação passa a monitorar.
Enquanto o PIT indicava se seus testes detectariam um bug comportamental e o ArchUnit informava se sua base de código ainda mantinha a estrutura projetada, o teste de contrato indica se dois sistemas implantados independentemente ainda concordam sobre como se comunicar. É o terceiro sensor no mesmo conjunto: um para o que o código faz, um para como o código é construído e agora um para como seus serviços cumprem suas promessas uns aos outros. Em um mundo onde um agente pode gerar ambos os lados de uma API antes do almoço, esse último sensor representa a diferença entre “o recurso funciona” e “o recurso funciona em conjunto”.
Referências
- Documentação do Pact – a estrutura de teste de contratos orientada ao consumidor usada aqui.
- Pact JS – a biblioteca consumidora de JavaScript/TypeScript
- Pact JVM – o verificador de provedores JVM, incluindo suporte ao Spring.
- Pact Broker – compartilhamento de contratos e o
can-i-deployportão - Spring Cloud Contract – a alternativa orientada a produtores para limites entre JVMs.
- Contratos Orientados ao Consumidor (Martin Fowler) – o padrão de testes de contrato implementado.
- loiane/crud-angular-spring – projeto de exemplo
Boa programação!
Mais de Loiane Groner
- Usando o GitHub Copilot para gerar mensagens de commit convencionais no VSCode e no IntelliJ IDEA
- Angular v21 e o Fim da Cobertura de Testes Exclusivamente em TypeScript: Por Que Precisamos Repensar os Testes de Componentes
- Testes de arquitetura para Java com ArchUnit: Pare de confiar no seu diagrama, comece a testá-lo.







