Engenharia
Código gerado por IA: requisitos que podem falhar
Um agente de programação consegue construir uma funcionalidade em minutos. Funciona na demonstração, os testes passam e o diff parece arrumado. Algumas semanas depois, falha numa situação que ninguém descreveu: uma conta com muito mais dados do que as fixtures, um portátil mais lento, uma rede real, alguém a usar um leitor de ecrã. O código gerado por IA não estava errado face ao pedido. O pedido é que nunca disse o que tinha de se manter nessas condições. Por isso, o agente preencheu as lacunas com suposições e depois escreveu testes que confirmaram o próprio código. Quando uma revisão de código ou um utilizador dá pelo problema, já há outro trabalho construído sobre esse desenho.
Nada disto começou com o desenvolvimento assistido por IA. Sempre se escreveu código contra requisitos que ninguém formulou. O desenvolvimento orientado por testes (TDD) foi uma das respostas mais práticas: escrever um teste que falha, fazê-lo passar e depois arrumar o código enquanto o teste mantém tudo honesto. Escrever o teste primeiro obriga a decidir o que significa “funcionar” antes de existir código, e o teste fica depois para apanhar regressões. Os testes de aceitação levaram mais tarde a mesma ideia das unidades isoladas para o comportamento que o utilizador vê.
Continuo a achar que o TDD compensa. Só que, sozinho, não cobre o caso acima. Na prática, orienta sobretudo comportamento funcional, uma unidade de cada vez. Também parte do princípio de que quem escreve o teste sabe aquilo de que o produto precisa, e com um agente é muitas vezes o mesmo sistema que escreve a implementação e os testes, pelo que os testes herdam as mesmas suposições. E há ainda o tipo de falha a que o frontend é propenso. Falha em aspetos não funcionais: quantos dados existem, quão rápido é o dispositivo, quando a rede responde, o que recebem as tecnologias de apoio. Os testes unitários escritos primeiro raramente falam de nada disto.
A parte do TDD que vale a pena manter é a ideia de que um requisito só se torna real quando alguma coisa pode falhar contra ele. A ordem em que os ficheiros são criados importa muito menos. E essa ideia funciona muito para lá dos testes unitários. “Os resultados de pesquisa pertencem sempre à consulta atual” pode ser uma verificação. Também podem ser “a lista mantém-se reativa com 5000 linhas num CPU limitado”, “uma sequência de teclas mantém no máximo um pedido em curso” e “o JavaScript desta rota fica dentro do orçamento”.
Se requisitos como estes existirem como verificações executáveis antes de o desenho começar, o agente pode testar a própria proposta contra eles. Percebe que uma lista ingénua não cumpre o orçamento de reatividade enquanto ainda está a escolher a abordagem, em vez de depois de um revisor ou um utilizador dar com o problema. O trabalho de qualidade passa para o momento em que o código é desenhado e escrito.
Cada requisito abaixo indica a condição, o resultado observável, o limite, se existir, e o ambiente onde é medido. O âmbito, a parte mais fácil de esquecer, aparece perto do fim. Os exemplos foram construídos para ilustração. Os números são orçamentos de exemplo, não recomendações para todos os produtos.
Uma lista rápida com cinquenta linhas
Peça a um agente uma lista de projetos com um campo de filtro, e uma primeira versão típica filtra um array a cada tecla e apresenta todos os resultados. Com as cinquenta linhas de fixture do repositório parece instantânea, e o próprio teste do agente confirma que escrever “billing” deixa no ecrã os projetos de faturação. As duas coisas são verdade. Nenhuma diz nada sobre uma conta com 5000 projetos num portátil de gama média.
Um prompt melhor não resolve isto. O que falta é um requisito com um tamanho, um limite e um ambiente:
Com 5000 projetos carregados e o CPU limitado 4×, nenhuma tecla no campo de filtro demora mais de 200 ms entre a entrada e a pintura seguinte.
Os 200 ms vêm do limite “bom” de Interaction to Next Paint (INP), a métrica de reatividade das Core Web Vitals. Aqui é um orçamento de exemplo para uma interação num teste de laboratório. O número 5000 importa tanto como o limite. Um agente testa com os dados que recebe, e um requisito que não indica um tamanho aceita em silêncio o tamanho das fixtures.
Numa configuração de Playwright com Chromium, o requisito pode tornar-se uma verificação:
import { expect, test } from '@playwright/test';
declare global {
interface Window {
__slowestInteraction: number;
}
}
test('filtering 5,000 projects stays within the interaction budget', async ({ browserName, page }) => {
test.skip(browserName !== 'chromium', 'CPU throttling uses the Chrome DevTools Protocol');
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
await page.goto('/projects?fixture=5000');
await page.evaluate(() => {
window.__slowestInteraction = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
window.__slowestInteraction = Math.max(window.__slowestInteraction, entry.duration);
}
}).observe({ type: 'event', buffered: true }); // reports interactions of 104 ms and longer
});
await page
.getByRole('searchbox', { name: 'Filter projects' })
.pressSequentially('billing', { delay: 120 });
await page.waitForTimeout(500); // let the last event-timing entries arrive
const slowest = await page.evaluate(() => window.__slowestInteraction);
expect(slowest).toBeLessThanOrEqual(200);
});O observer lê entradas de Event Timing. A duração cobre o atraso da entrada, o trabalho dos handlers e o tempo até a frame seguinte ser apresentada. A limitação do CPU através do Chrome DevTools Protocol só existe no Chromium, por isso o teste ignora-se a si próprio nos outros motores.
Esse limite aplica-se ao orçamento, não ao requisito inteiro. O Playwright corre o mesmo ficheiro de teste em Chromium, Firefox e WebKit, pelo que a metade funcional (ficam os projetos certos depois de filtrar 5000) pode correr nos três. O trabalho interativo também já não está preso ao Chromium. O Safari 27 inclui um servidor MCP, que o WebKit apresentou em julho de 2026 e que se ativa nas definições de Programador em “Allow remote automation and external agents”. A Mozilla publica o Firefox DevTools MCP, que controla o Firefox através do WebDriver BiDi. Nenhum dos dois documenta limitação de CPU, por isso o orçamento continua a ser uma medição no Chromium. O comportamento pode ser verificado em todos os motores que o produto suporta.
Com a verificação disponível enquanto desenha, a primeira tentativa do agente falha, e essa falha é útil. Agora pode comparar opções reais: virtualizar as linhas, paginar, adiar a atualização da lista em relação à entrada ou passar o filtro para o servidor. A verificação não escolhe por ele. Elimina a opção que não cumpre o requisito, antes de alguém construir um ecrã em cima dela.
Cada opção restante tem um custo, e esses custos também são requisitos. As linhas virtualizadas não estão no DOM, por isso a pesquisa na página não as encontra, e as tecnologias de apoio precisam de saber o número real de linhas. Se o produto depende de alguma destas coisas, escreva-a como uma segunda verificação. Um agente deve ver os requisitos que puxam em sentidos diferentes antes de se comprometer com um desenho.
As verificações de desempenho têm mais ruído do que as funcionais. Corra-as numa máquina consistente, deixe uma margem abaixo do orçamento e repita uma execução falhada antes de lhe chamar regressão. Uma execução de laboratório que passa diz pouco sobre os utilizadores reais. Diz que este desenho cabe neste orçamento nestas condições, e é tudo o que o requisito afirma.
Uma caixa de pesquisa que multiplica a carga no servidor
Agora passe o filtro para o servidor, porque a conta real tem mais projetos do que o browser deve descarregar. Uma primeira versão comum envia um pedido a cada tecla. Escrever “billing” dispara sete pedidos, e seis respondem a perguntas que o utilizador ainda não tinha acabado de fazer. Contra uma API local, na máquina de quem programa, nada parece errado. Em produção, o custo cresce com cada pessoa a escrever ao mesmo tempo e cai num serviço que a equipa de frontend pode nem gerir.
O agente não vê esse custo a menos que o requisito o indique:
Escrever uma consulta de sete caracteres a 120 ms por tecla, contra uma API que demora 300 ms, envia no máximo dois pedidos de pesquisa, nunca mantém mais de um em curso e mostra os resultados da consulta final até 800 ms depois da última tecla.
Este requisito aperta o desenho dos dois lados. O limite de pedidos exclui enviar a cada tecla. O limite de 800 ms exclui uma espera longa antes de enviar: com uma API de 300 ms, um debounce muito acima de algumas centenas de milissegundos não deixa margem. O agente tem de encontrar algo que caiba entre os dois, e o teste diz-lhe se conseguiu.
O Playwright pode fazer de API lenta e contar o que a página envia:
import { expect, test, type Request } from '@playwright/test';
declare global {
interface Window {
__searchTiming: { lastInput: number; rendered: number };
}
}
const isSearch = (url: string) => url.includes('/api/projects?q=');
test('typing a query keeps search load bounded', async ({ page }) => {
let started = 0;
let inFlight = 0;
let maxInFlight = 0;
await page.route('**/api/projects?q=*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 300)); // slow but healthy API
const query = new URL(route.request().url()).searchParams.get('q') ?? '';
await route.fulfill({ json: projectsMatching(query) }).catch(() => {}); // may be aborted
});
page.on('request', (request) => {
if (!isSearch(request.url())) return;
started += 1;
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
});
const settle = (request: Request) => {
if (isSearch(request.url())) inFlight -= 1;
};
page.on('requestfinished', settle);
page.on('requestfailed', settle);
await page.goto('/projects');
await page.evaluate(() => {
// measure inside the page: the test runner's retries are too coarse for timing
const timing = { lastInput: 0, rendered: 0 };
window.__searchTiming = timing;
document.addEventListener('input', () => (timing.lastInput = performance.now()), true);
new MutationObserver(() => (timing.rendered = performance.now())).observe(
document.querySelector('[role="status"]')!,
{ childList: true, characterData: true, subtree: true },
);
});
await page
.getByRole('searchbox', { name: 'Filter projects' })
.pressSequentially('billing', { delay: 120 });
await expect(page.getByRole('status')).toHaveText('12 projects match "billing"');
const { lastInput, rendered } = await page.evaluate(() => window.__searchTiming);
expect(rendered - lastInput).toBeLessThanOrEqual(800);
expect(started).toBeLessThanOrEqual(2);
expect(maxInFlight).toBe(1);
});projectsMatching representa uma fixture que devolve um conjunto de resultados conhecido, para que o texto de estado possa ser exato. O tempo é medido dentro da página de propósito. Quando o medi a partir do processo de teste, os intervalos de nova tentativa do Playwright faziam uma resposta de cerca de 575 ms parecer de 830 ms, e o teste reprovava um desenho que cumpria o orçamento. Os eventos de pedido, ao contrário da limitação de CPU, funcionam nos três motores, por isso esta verificação corre inteira em Chromium, Firefox e WebKit.
Com a verificação em vigor, um desenho que passa combina um debounce curto com o cancelamento do pedido anterior através de um AbortController. Cancelar reduz a carga. Não garante resultados corretos, porque uma resposta pode chegar antes de o cancelamento ter efeito. Manter os resultados ligados à consulta que os produziu é um requisito separado e precisa da sua própria verificação. Essa verificação impede que um agente trate “os pedidos são cancelados” como prova de que não aparecem resultados desatualizados.
Esta verificação cobre um padrão de escrita. Alguém que faz uma pausa a meio de uma palavra é outro caso. Se o produto se preocupa com isso, dê-lhe um cenário próprio em vez de alargar o limite deste.
Um botão que o agente “corrige” para pior
Nem todos os requisitos são sobre velocidade ou carga. Alguns existem para proteger comportamento que já funciona contra alterações bem-intencionadas.
Imagine que pede a um agente para melhorar a acessibilidade da barra de ferramentas de um relatório. Ele conhece uma regra verdadeira, a de que botões só com ícone precisam de um nome acessível, e aplica-a de forma demasiado ampla. Os botões com texto também recebem um aria-label:
<button type="button" aria-label="Submit">Save report</button>O botão já tinha nome. Um botão nativo recebe o nome a partir do texto, como explica a orientação do APG sobre nomes acessíveis. O aria-label substituiu “Save report” por “Submit”. Um utilizador de controlo por voz que diga “click Save report” está agora a falar com um botão que as tecnologias de apoio conhecem por outro nome. O WCAG 2.5.3 Label in Name existe precisamente para esta incoerência. Também é uma alteração fácil de aprovar numa revisão de código. O diff só acrescenta um atributo, e o atributo parece uma correção de acessibilidade, enquanto a barra de ferramentas ficou pior.
O requisito que o evita é curto:
Todos os botões da barra de ferramentas do relatório têm um nome acessível que contém o texto visível. Os botões só com ícone têm um nome que descreve a ação.
O Playwright calcula nomes acessíveis, por isso o requisito pode cobrir a barra inteira e não apenas um botão:
import { expect, test } from '@playwright/test';
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
test('toolbar buttons are named by what people see', async ({ page }) => {
await page.goto('/reports/demo');
const toolbar = page.getByRole('toolbar', { name: 'Report actions' });
for (const button of await toolbar.getByRole('button').all()) {
const visible = (await button.innerText()).trim();
if (visible) {
await expect(button).toHaveAccessibleName(new RegExp(escapeRegExp(visible), 'i'));
}
}
await expect(toolbar.getByRole('button', { name: 'Delete report' })).toBeVisible();
});O ciclo aplica a regra a todos os botões com texto, incluindo os que forem acrescentados mais tarde. A última verificação confirma o botão só com ícone pelo nome pretendido. Um agente que acrescente um aria-label incoerente falha agora uma verificação que pode correr sozinho, antes de alguém rever a alteração.
A verificação tem limites. O cálculo de nomes do Playwright aproxima o que os browsers expõem. Não é uma sessão com leitor de ecrã, e um teste que passa não torna a página conforme com as WCAG. Mostra uma propriedade de uma barra de ferramentas, e é tudo o que o requisito afirma.
Os requisitos precisam de um âmbito e de um sítio
As três verificações acima protegem três sítios do código. A arquitetura por trás delas é mais ampla. Seis meses depois, alguém pede a um agente um segundo ecrã de pesquisa, desta vez para relatórios arquivados, e o agente escreve o seu próprio pedido a cada tecla. O teste antigo continua a passar, porque cobre o ecrã de projetos, e nada diz ao agente que a decisão anterior valia para todas as pesquisas do produto. Cada verificação cobre um ecrã, por isso a intenção por trás dela pode perder-se sem nenhum teste ficar vermelho. Ninguém decide assumir esse tipo de dívida técnica. Ela acumula-se no código gerado por IA, um ecrã de aspeto razoável de cada vez.
Há duas coisas que travam essa deriva. Uma é uma verificação que cobre todo o código e não apenas um ecrã, por exemplo uma regra de imports que só deixa os pedidos de pesquisa passar pelo módulo de pesquisa partilhado. A arquitetura evolutiva chama a verificações destas fitness functions: medidas automáticas de quão perto o sistema se mantém de um objetivo de arquitetura. A outra é um sítio onde o requisito, o motivo e o âmbito ficam escritos, para que um agente que constrói algo novo encontre a regra antes de existir qualquer teste para ela.
Ferramentas orientadas por especificações, como o OpenSpec e o GitHub Spec Kit, dão estrutura a esse sítio: requisitos com cenários, guardados no repositório e lidos pelo agente quando propõe uma alteração. Importa-me menos o formato do que algo com que os exemplos anteriores foram tropeçando. Os requisitos não funcionais são condicionais. O orçamento das 5000 linhas importa para listas de dados do utilizador que crescem. Numa página de definições com dez entradas é esforço desperdiçado. Sem âmbito, o requisito é aplicado de forma demasiado estreita, e o novo ecrã sai sem ele, ou demasiado ampla, e um agente virtualiza todas as listas do produto. O segundo erro é outra vez o do botão: uma regra correta no sítio errado.
Por isso, ponha o âmbito no próprio requisito:
### Requirement: Responsive filtering of large collections
For any view that filters a user-owned collection which can exceed 1,000 items,
the system SHALL keep each filter keystroke within 200 ms from input to next paint.
Views over fixed or small sets are out of scope.
Reason: accounts grow, and small fixtures hide the cost.
#### Scenario: Filtering 5,000 projects on a slow CPU
- **WHEN** 5,000 projects are loaded, the CPU is throttled 4× and the user types "billing"
- **THEN** no keystroke takes longer than 200 ms from input to next paintO cenário descreve o teste do primeiro exemplo, e o teste pode remeter de volta para o cenário. Quando chegar a pesquisa do arquivo, o agente tem uma regra que diz se o novo ecrã está no âmbito, e um revisor tem algo concreto a apontar se o agente falhar. É a quinta parte de um requisito verificável: condição, resultado observável, limite, ambiente e o âmbito a que se aplica.
Um requisito que só vive num prompt dura uma conversa. Numa especificação e numa verificação no repositório, aplica-se a todas as alterações seguintes, seja quem for ou o que for que as faça. É isso que leva de facto um agente a rever o próprio trabalho: um comando de testes que se espera que corra, um hook depois de cada edição, uma barreira antes do push que não deixa passar um resultado vermelho. Pedir-lhe cuidado faz muito menos. E nem todos os requisitos precisam de um browser. O orçamento de JavaScript de uma rota pode ser uma verificação de tamanho no build, e um erro que continua a voltar pode tornar-se uma regra de lint.
O TDD pedia o mesmo: decidir o que significa “funcionar” antes de o código existir. O que mudou foi quem lê o requisito. Agora é algo que escreve código depressa e confia nos próprios testes, por isso o requisito tem de ser específico o suficiente para falhar. Testes verdes, por si só, não deixam o código gerado por IA pronto para produção.
Se o produto já está em funcionamento, a parte difícil é muitas vezes descobrir de que requisitos depende sem o dizer e quais deles nada verifica. É por aí que começa uma Frontend Risk Audit: as tarefas que importam, o comportamento de que dependem e uma lista priorizada do que verificar e corrigir.