Al terminar esta lección
- Ejercitar fronteras, rollback, reintentos y concurrencia real con tests que observan efectos y ausencia de efectos.
- Detectar un oráculo débil leyendo un test que pasa por la razón equivocada.
- Justificar el mecanismo de reserva frente a alternativas válidas, explicando por qué descartaste cada una.
- Convertir un test imposible de escribir en una pregunta de negocio con estado BLOCKED y responsable.
10.8 Elegir el nivel por la propiedad que querés defender
Para CU-01, el equipo tenía 184 tests verdes en CI y ningún test que demostrara RN-05 bajo competencia. Antes de escribir código nuevo, separaron las propiedades que debían defenderse y, para cada una, el nivel mínimo que podía observarla. El resultado no fue una pirámide más prolija. Fue una lista corta de preguntas con un observador asignado a cada una:
- ¿El cálculo de crédito en el límite exacto y un centavo por encima es correcto? Unit, con las mismas cifras que el caso.
- ¿Una falla antes del commit deja la base sin pedido, líneas, reservas ni registro de idempotencia? Integration con PostgreSQL real.
- ¿Dos vendedores que piden la última unidad generan exactamente una reserva? Integration concurrente con barrera.
- ¿Un reintento después de perder la respuesta duplica el pedido? Integration con dos llamadas y un canal que pierde la respuesta.
- ¿El proveedor fiscal caído o con respuesta ambigua invalida el pedido ya confirmado? Contract test contra un stub que reproduce el contrato.
Lo que no hicieron es igual de importante. No escribieron un integration test para cada función del dominio. No mockearon PostgreSQL para ir más rápido en las propiedades de la base. Y no tomaron la cobertura como criterio de salida. Cada decisión tiene una razón: un nivel más barato no podía ver el mecanismo, o el mecanismo no existía todavía.
Nexo B2B · El caso de la suite que no podía fallar
Escena 1 · El mock que siempre tenía stock
El equipo tenía un test llamado "rechaza la última unidad cuando el depósito no tiene stock". Estaba en la carpeta de API, pasaba en CI y se leía bien en la revisión. Antes de aprobarlo, alguien lo ejecutó contra una versión del caso de uso que había borrado a propósito la validación de stock. Seguía verde.
Este es el código del test original. La numeración de líneas te sirve para el ejercicio que sigue.
¿Por qué el test sigue verde sin validar stock?
Hacé clic en las líneas que dejan el test sin poder fallar por la regla que dice defender. Después comprobá tu lectura.
El test tenía tres defectos, y ninguno era de sintaxis. El primero es el fake que decide el resultado (línea 6). El segundo es un error de contrato que el test ocultaba (línea 13): el campo se llama quantity y el test enviaba qty, así que el rechazo era de esquema. El tercero es el assert de status (línea 15), que no verifica efectos: ni reserva, ni pedido, ni cambio de stock.
La corrección no es agregar otro test al lado. Es reemplazar el fake por el stock real para este riesgo, usar el contrato correcto del request y agregar las aserciones de efectos ausentes que vimos en 10.4. Después, el test se valida con la prueba de la prueba: si borrás la regla, tiene que ponerse rojo.
Escena 2 · La última unidad: dos conexiones y una barrera
El depósito CBA tiene una unidad disponible de AX-442. Dos vendedores la piden casi al mismo tiempo, desde dos sesiones distintas. El caso de uso ya había sido diseñado con la regla RN-05 en mente, pero la implementación inicial leía el stock, decidía en la aplicación y escribía después. Esa versión es la que sigue.
¿Dónde está la carrera en la reserva?
Hacé clic en las líneas donde el stock se lee, se decide y se escribe sin que la base proteja la condición.
Fijate en el último punto. El constraint de la base salvó el dato, y eso es valioso como defensa en profundidad. Pero si el test solo verifica que la disponibilidad no es negativa, puede pasar con un error 500 en lugar de un rechazo de negocio. El oráculo tiene que mirar el tipo de resultado y no solo el estado final.
La versión correcta hace la condición y la escritura en una sola sentencia. La base resuelve la carrera con el lock que toma sobre la fila que actualiza.
create table stock_level (
product_id text not null,
warehouse_id text not null,
on_hand integer not null check (on_hand >= 0),
reserved integer not null default 0,
primary key (product_id, warehouse_id),
-- RN-05: la disponibilidad (on_hand - reserved) nunca es negativa
constraint rn05_available_not_negative check (reserved >= 0 and reserved <= on_hand)
);
import type { Queryable, ReserveInput, ReserveResult } from "../ports/stock";
export async function reserveStock(db: Queryable, { orderId, depotId, sku, quantity }: ReserveInput): Promise<ReserveResult> {
// La condición y la escritura son una sola sentencia: no hay ventana entre leer y escribir.
const res = await db.query(
`update stock_level
set reserved = reserved + $3
where product_id = $1
and warehouse_id = $2
and on_hand - reserved >= $3`,
[sku, depotId, quantity],
);
if (res.rowCount === 0) return { kind: "InsufficientStock" };
await db.query(
"insert into reservation (order_id, product_id, warehouse_id, quantity) values ($1, $2, $3, $4)",
[orderId, sku, depotId, quantity],
);
return { kind: "Reserved" };
}
Ahora el test. Tiene dos conexiones reales, una barrera que las hace competir y un oráculo que mira el resultado de cada operación, el estado final de la disponibilidad y la cantidad de reservas persistidas.
import { test } from "node:test";
import assert from "node:assert/strict";
import { Pool, type PoolClient } from "pg";
import { reserveStock } from "../../src/adapters/postgres-stock";
import { barrier } from "../support/barrier";
const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
test("T06: exactamente una reserva gana la última unidad (AC-06, RN-05)", async () => {
await pool.query("delete from reservation where product_id = $1 and warehouse_id = $2", ["AX-442", "CBA"]);
await pool.query(
"update stock_level set on_hand = 1, reserved = 0 where product_id = $1 and warehouse_id = $2",
["AX-442", "CBA"],
);
const gate = barrier(2);
const attempt = async (orderId: string) => {
const client: PoolClient = await pool.connect();
try {
await client.query("begin");
await gate.wait(); // las dos transacciones ya están abiertas y listas
const result = await reserveStock(client, { orderId, depotId: "CBA", sku: "AX-442", quantity: 1 });
await client.query("commit");
return result;
} catch (err) {
await client.query("rollback");
throw err;
} finally {
client.release();
}
};
const results = await Promise.all([attempt("O-A"), attempt("O-B")]);
assert.deepEqual(results.map(r => r.kind).sort(), ["InsufficientStock", "Reserved"]);
const { rows: stock } = await pool.query(
"select on_hand - reserved as available from stock_level where product_id = $1 and warehouse_id = $2",
["AX-442", "CBA"],
);
assert.equal(stock[0].available, 0);
const { rows: reservas } = await pool.query(
"select count(*)::int as n from reservation where product_id = $1 and warehouse_id = $2",
["AX-442", "CBA"],
);
assert.equal(reservas[0].n, 1); // y ninguna reserva parcial ni duplicada
});
El test se ejecuta contra la misma base y el mismo aislamiento que producción. Para confirmar que defiende algo, se corre también contra la versión ingenua de la escena anterior. Ahí no se obtiene un resultado de dominio: el segundo update viola el CHECK y Promise.all rechaza con un error de constraint. El test sale rojo, que es lo que queremos. Si hubiera salido verde, el problema sería el test.
Test de carrera débil frente a test de carrera que defiende la invariante
Lanza dos llamadas con Promise.all sin barrera y verifica que "algún request falla". Puede pasar aunque las dos reservas se hayan persistido y una falle después por otra razón. No mira el estado final ni la cantidad de reservas.
Sincroniza las transacciones con barrera, exige exactamente un Reserved y un InsufficientStock, verifica disponibilidad final 0 y una sola fila en reservation. Si cualquiera de esas condiciones falla, el test muestra cuál.
Decisiones y alternativas descartadas. El mecanismo elegido no era el único posible. Estas son las alternativas que el equipo evaluó:
- Leer, decidir y escribir en la aplicación. Descartada: es exactamente el BugHunt de arriba. Deja una ventana entre la lectura y la escritura que el test de T06 demuestra.
SELECT ... FOR UPDATEseguido de la actualización. Válida y correcta. Se consideró como segunda opción porque la protección depende de que todo escritor tome el lock; cualquier escritura nueva que lo omita reabre la carrera. La actualización condicional tiene la condición en la misma sentencia.- Transacción serializable con reintento. Válida. El test debería aceptar que una transacción falle con error de serialización y se reintente, porque el contrato observable es el mismo. Se descartó por costo operativo y porque obliga a manejar el reintento en todos los caminos.
- Contador en Redis o una cola. Descartada. La reserva tiene que compartir commit con el pedido, la línea y el registro de idempotencia. Un contador externo no puede participar de esa transacción y reintroduce el problema de consistencia entre dos sistemas.
Esta decisión se documenta en un ADR con el contexto, la alternativa descartada, la consecuencia y la prueba concurrente requerida. La prueba es T06.
Mirá el momento en que la segunda transacción espera el bloqueo de la primera. Es la base la que serializa la decisión, no el código de la aplicación. Por eso el test tiene que usar PostgreSQL real: un fake no tiene esa espera y no puede mostrar lo que pasa.
Escena 3 · Un centavo sobre el límite
Laura, de administración, revisó el caso de crédito con el equipo. El escenario "un centavo sobre el límite" existía, pero su test solo comprobaba que la respuesta era un rechazo. Cuando lo revisaron con el oráculo completo, descubrieron algo peor: una versión anterior del caso de uso reservaba stock antes de evaluar el crédito. El rechazo llegaba, el test pasaba, y la reserva quedaba persistida.
El test correcto es el de 10.4. Lo repetimos aquí porque es el que se ejecuta en la integración, con el mismo fixture y la misma base:
import { test } from "node:test";
import assert from "node:assert/strict";
import { fixture } from "../support/fixture";
test("T03: un centavo sobre el límite rechaza sin efectos (AC-03, RN-01)", async () => {
const env = await fixture({
client: { id: "CLI-0042", tier: "estandar", balanceCents: 48_000_000, creditLimitCents: 50_000_000 },
stock: { "AX-442": { CBA: 10 } },
priceCents: { "AX-442": 2_000_001 },
});
const result = await env.createOrder({
requestId: "req-501",
sellerId: "SEL-01",
clientId: "CLI-0042",
deliveryMethod: "pickup",
items: [{ sku: "AX-442", quantity: 1 }],
});
assert.deepEqual(result, { kind: "rejected", reason: "credit" });
assert.equal(await env.countOrders("req-501"), 0);
assert.equal(await env.reservedUnits("AX-442", "CBA"), 0);
assert.equal(await env.balanceCents("CLI-0042"), 48_000_000);
});
El orden canónico de CU-01 es validar, calcular precio, evaluar crédito y reservar en la misma transacción. Con ese orden, la reserva nunca llega a persistirse si el crédito rechaza. El test confirma que el orden se respeta: si alguien lo invierte, la aserción de reservedUnits lo detecta aunque la respuesta sea la misma.
La autorización merece su propio tratamiento. CU-02 existe porque la autorización de una excepción de crédito es un permiso separado, con motivo obligatorio y auditoría. El test de T04 verifica que el pedido queda en PENDIENTE_AUTORIZACION y que la solicitud queda registrada. No verifica que un usuario sin permiso pueda autorizar: eso es T10 y NFR-03. Y no fija ninguna tolerancia Platino, porque RN-02 sigue BLOCKED.
Escena 4 · Idempotencia: el pedido que se confirmó y la respuesta que no llegó
La vendedora Sofía envió un pedido desde el celular, en un ascensor con mala señal. La app mostró timeout. Reintentó. Sin idempotencia, el sistema habría creado dos pedidos con el mismo contenido y reservado dos veces la misma unidad. Con RN-06, el mismo requestId y el mismo contenido deben producir el mismo pedido.
La secuencia real es esta: la transacción confirma el pedido, la respuesta se pierde en la red y el cliente reintenta.
La clave de idempotencia no alcanza por sí sola. Hace falta un fingerprint del contenido, para distinguir "el mismo requestId con el mismo pedido" de "el mismo requestId con otro pedido". ADR-006 define ese fingerprint sobre cliente, depósito y líneas normalizadas por SKU y cantidad, e ignora la metadata de trazabilidad.
import { createHash } from "node:crypto";
export type OrderIntent = {
clientId: string;
preferredDepositId?: string;
items: Array<{ sku: string; quantity: number; traceId?: string }>;
};
export function requestHash(intent: OrderIntent): string {
const lines = intent.items
.map(({ sku, quantity }) => ({ sku, quantity })) // traceId no entra en el fingerprint
.sort((a, b) => a.sku.localeCompare(b.sku));
const canonical = JSON.stringify({
clientId: intent.clientId,
depositId: intent.preferredDepositId ?? null,
lines,
});
return createHash("sha256").update(canonical).digest("hex");
}
Hay una pregunta abierta que el equipo dejó visible en lugar de resolverla en silencio: ADR-006 no menciona el método de entrega. Si dos pedidos tienen las mismas líneas y cambian de retiro a envío, ¿son la misma intención? Hasta que negocio lo defina, el test no puede tener oráculo para ese caso, y lo marcamos como desconocido.
Los dos tests de idempotencia cubren las dos direcciones. El primero verifica el reintento tras la respuesta perdida. El segundo verifica el conflicto cuando el requestId se reutiliza con un contenido distinto.
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildOrderApi, lossyChannel, pool } from "../support/order-env";
const cmd = {
requestId: "req-707",
sellerId: "SEL-03",
clientId: "CLI-0042",
deliveryMethod: "pickup" as const,
items: [{ sku: "AX-442", quantity: 1 }],
};
test("T07: reintento tras respuesta perdida después del commit no duplica (AC-07, RN-06)", async () => {
const api = buildOrderApi({ db: pool, channel: lossyChannel({ dropResponseAfterCommitOnce: true }) });
await assert.rejects(api.createOrder(cmd), /response lost/); // el commit ya ocurrió
const retry = await api.createOrder(cmd);
assert.equal(retry.kind, "confirmed");
const { rows } = await pool.query("select count(*)::int as n from orders where request_id = $1", ["req-707"]);
assert.equal(rows[0].n, 1); // un único pedido, una única reserva
});
test("T08: mismo requestId con payload distinto devuelve conflicto sin segundo efecto (AC-07, RN-06)", async () => {
const api = buildOrderApi({ db: pool, channel: lossyChannel({}) });
const first = await api.createOrder({ ...cmd, requestId: "req-708" });
assert.equal(first.kind, "confirmed");
const second = await api.createOrder({ ...cmd, requestId: "req-708", items: [{ sku: "AX-442", quantity: 2 }] });
assert.deepEqual(second, { kind: "conflict", reason: "request_id_reused" });
const { rows } = await pool.query("select count(*)::int as n from orders where request_id = $1", ["req-708"]);
assert.equal(rows[0].n, 1);
});
El lossyChannel es un helper de tests/support que, con la opción indicada, ejecuta el handler completo, deja que el commit ocurra y después descarta la respuesta. La base se reinicia entre tests para que req-707 y req-708 no se contaminen.
Decisión de códigos HTTP. El conflicto de idempotencia responde 409 y el rechazo de crédito responde 422. El caso no fijaba esos códigos, y sin una decisión los tests terminarían comparando números que cada desarrollador eligió al azar. Lo dejamos escrito en el contrato del API y lo verificamos en los tests, con el resultado de dominio conflict y rejected como fuente de verdad.
Escena 5 · Integración fiscal: la respuesta que no sabemos si llegó
El proveedor fiscal es externo. Puede responder con éxito, con un rechazo permanente, con un rechazo transitorio, con un timeout antes de procesar o con un timeout después de procesar. Este último es el caso difícil: el proveedor emitió el comprobante, pero nosotros no recibimos nada. Si el worker reintenta a ciegas, el resultado es un comprobante duplicado.
En el primer release la emisión fiscal está fuera de alcance: el pedido queda marcado como pendiente de facturación. Aun así, el equipo diseñó el adaptador y sus contract tests ahora, para que la etapa de integración no empiece con un contrato asumido. El nombre de los estados (emitido, desconocido, revisión) es ilustrativo y tiene que validarlo administración antes de usarlo en producción.
import { test } from "node:test";
import assert from "node:assert/strict";
import { fiscalWorker } from "../../src/workers/fiscal-worker";
import { fakeFiscalProvider } from "../support/fake-fiscal-provider";
import { invoiceStateOf, seedIntent } from "../support/outbox-env";
test("timeout después de procesar: el worker consulta por idempotency key y no vuelve a emitir", async () => {
const intentId = await seedIntent("req-901");
const provider = fakeFiscalProvider(["emitted_then_timeout"]); // el proveedor emitió y no respondió
await fiscalWorker({ provider }).process(intentId);
assert.equal(provider.emissionsFor(intentId), 1); // un solo comprobante en el proveedor
assert.equal(await invoiceStateOf(intentId), "emitido"); // el estado local converge al del proveedor
});
test("respuesta inválida: sin identificador el comprobante no se marca como emitido", async () => {
const intentId = await seedIntent("req-902");
const provider = fakeFiscalProvider([{ status: "ok", voucherNumber: "0001-00001234" }]); // falta el identificador
await fiscalWorker({ provider }).process(intentId);
assert.equal(await invoiceStateOf(intentId), "pendiente_revision");
});
Estos dos tests defienden la traducción que la aplicación asume, no la infraestructura del proveedor. Si el proveedor cambia el formato o el significado de su timeout, el contrato se pone rojo y el equipo lo sabe antes de producción. Un mock que siempre responde ok con identificador no haría ninguna de esas cosas.
Hay una consecuencia de diseño que sale de este caso y que conecta con T12. Un fallo del proveedor fiscal, incluso después del commit del pedido, no debe deshacer el pedido ni impedir que la operación externa se reintente. Por eso la intención fiscal se escribe en la misma transacción que el pedido, como registro en el outbox, y el worker la procesa después.
Escena 6 · El test que certificaba un precio
Un test de "precio correcto" enviaba unitPrice en el request y verificaba que se guardara ese valor. Pasaba. El test estaba certificando una vulnerabilidad: cualquier cliente podía fijar el precio de una línea. La regla RN-03 dice que el precio se calcula en el servidor desde la lista vigente y los acuerdos autorizados.
El test se reescribe como regresión. Ahora el cliente manda un precio falso a propósito y el sistema tiene que ignorarlo:
import { test } from "node:test";
import assert from "node:assert/strict";
import { env } from "../support/order-env";
test("regresión: el precio lo resuelve el servidor aunque el cliente mande otro (AC-04, RN-03)", async () => {
await env.seedPrice({
clientId: "CLI-0042",
sku: "AX-442",
listCents: 1_245_000,
agreedCents: 1_190_000,
validUntil: "2026-12-31",
});
const result = await env.createOrder({
requestId: "req-812",
sellerId: "SEL-01",
clientId: "CLI-0042",
deliveryMethod: "pickup",
// el tipo del request no declara unitPrice: el test lo manda igual, a propósito
items: [{ sku: "AX-442", quantity: 1, unitPrice: 1 }],
});
assert.equal(result.kind, "confirmed");
const line = await env.orderLine(result.orderId, "AX-442");
assert.equal(line.unitPriceCents, 1_190_000); // precio acordado vigente, guardado como snapshot
});
El acuerdo lleva validUntil. Un precio especial sin vigencia validada no se inventa: el pedido queda pendiente de revisión comercial. Por eso el fixture incluye una fecha, y un test aparte debería cubrir el caso sin fecha.
Escena 7 · Un test imposible: el TTL de la reserva
El equipo intentó escribir el test de qué pasa con una reserva blanda cuando un pedido espera autorización más de un día. Nadie pudo escribir el oráculo. La regla RN-07 dice que la reserva expira pasado "un tiempo todavía no definido". No hay número, no hay responsable de fijarlo y no está claro si el estado de espera conserva stock.
La tentación fue poner 24 horas y seguir. El test habría pasado y habría validado una cifra que nadie aprobó. Esta es la conversación que el equipo tuvo, en versión de escenario:
¿Cuánto tiempo puede estar apartada la unidad?
Estás en el depósito de CBA con Sergio y con Marta. Tenés 15 minutos. El pedido de AX-442 sigue pendiente de autorización de crédito desde hace tres días.
Sergio (depósito)
Las 3 unidades de AX-442 siguen apartadas por un pedido que espera autorización. ¿Cuánto tiempo puede estar así?
¿Qué respondés?
El test que queda en el repositorio es un marcador honesto. node:test permite marcar un caso como pendiente con un motivo, y eso lo deja visible en la salida de la suite:
import { test } from "node:test";
test("la reserva blanda de un pedido pendiente expira (RN-07)", { todo: "BLOCKED: RN-07 no define el TTL; responsable depósito y comercial" }, async () => {
// Se escribe cuando negocio valide el TTL y el estado del stock durante la espera.
});
Cuando la política se resuelva, se actualizan la regla, la máquina de estados, la implementación y los tests. La trazabilidad muestra ese recorrido. La incapacidad de escribir el test fue la señal de que faltaba una decisión.
Escena 8 · La matriz que se presenta al cliente
En la demo para el dueño no se muestran cientos de asserts. Se muestran las promesas sometidas a presión: un pedido normal, el límite exacto, un centavo sobre el límite, una autorización que queda pendiente, un SKU inválido, la última unidad con dos vendedores, un reintento que no duplica, una falla de persistencia que no deja nada a medias y un proveedor externo caído que no invalida el pedido.
Cada fila de la matriz de 10.3 se lee así: promesa, mecanismo que podría romperla, nivel que la observa y evidencia que se puede repetir. El cliente no tiene que leer el código para entender qué se sometió a prueba. Y el equipo también dice qué no probó: el NFR de carga todavía no se midió, la facturación fiscal está fuera del primer release, y TTL, tolerancia Platino y cheques pendientes siguen BLOCKED.
Esa transparencia pesa más que una cifra de cobertura. Un cliente que ve "no probamos esto todavía, y por qué" confía más en lo que sí está probado.
Evidencia de ejecución: qué guardar
La evidencia técnica tiene que permitir que otra persona repita la ejecución. Para cada corrida, guardá al menos lo siguiente:
Suite: integration concurrente y contract de CU-01
Commit probado: <hash del commit>
Entorno: PostgreSQL <versión>, aislamiento por defecto, misma configuración que producción
Dataset: fixture de CBA con AX-442 en stock 1 para T06
Comando: <comando exacto de ejecución de la suite>
Ejecuciones de la prueba concurrente: <cantidad> corridas, <cantidad> pasaron
Prueba de la prueba: T06 ejecutado contra la versión ingenua: resultado <rojo o verde>
Resultado: <pasaron> de <total>, con salida textual adjunta
Para una carrera, lo que más cuenta es la cantidad de ejecuciones y la prueba de la prueba. Para un contract test, la versión del proveedor o del sandbox. Para performance, los percentiles, la carga y el hardware (se ve en 10.8 y 10.3).
Errores frecuentes
- Síntoma: el test de rollback pasa, pero quedan pedidos o líneas a medio escribir. Causa: el oráculo verifica el código de error y no el estado de las tablas. Corrección: consultá pedidos, líneas, reservas y registros de idempotencia después del fallo inyectado.
- Síntoma: el test de concurrencia falla con un error de constraint en lugar de un resultado de negocio. Causa: la implementación no decide con la base y el constraint es el único que protege el dato. Corrección: la operación tiene que devolver InsufficientStock; el constraint es la red de seguridad, no el contrato.
- Síntoma: el test de idempotencia pasa con dos pedidos distintos. Causa: el requestId se usa como clave de búsqueda pero el fingerprint no se compara. Corrección: el registro guarda request_hash y el caso de conflicto se prueba explícitamente.
- Síntoma: el test de regresión de precio pasa aunque el cliente manda un precio falso. Causa: el test solo verifica el precio de la respuesta y no el que se guardó como snapshot. Corrección: verificá la línea persistida y el precio de la lista y el acuerdo vigente.
- Síntoma: el test del TTL tiene un número en el expected. Causa: alguien tomó una cifra de una conversación y la codificó sin validar. Corrección: marcalo como todo con su motivo y llevá la decisión a negocio.
Qué puede hacer el agente: proponer casos frontera y combinaciones de alto riesgo, generar el esqueleto de los tests de integración, revisar qué ramas del adaptador no tienen un test que las observe y resumir la salida de la suite.
Qué no debe decidir: el oráculo cuando la regla es ambigua (TTL, tolerancia Platino, precios sin vigencia), si un mock puede reemplazar la base en un riesgo de concurrencia, ni el código de error de un contrato.
Contexto mínimo útil: RN y AC relevantes, la matriz de 10.3, el adaptador bajo prueba, el esquema de la base y el comando real de ejecución.
Prompt de ejemplo:
Contexto: CU-01 de Nexo. Adaptador en src/adapters/postgres-stock.ts. Esquema en db/schema.sql.
Tarea: escribí un integration test node:test para T09 (falla inyectada antes del commit). El oráculo debe verificar que no quedan pedido, líneas, reservas ni registro de idempotencia.
Restricciones: usá PostgreSQL real, no mocks del repositorio. No fijes valores de TTL ni de tolerancia Platino. Los códigos HTTP están en el contrato: 422 para rechazo de crédito y 409 para conflicto de idempotencia.
Salida: el archivo del test, y tres líneas: qué defecto detecta, qué defecto no detecta y cómo verificaste que falla contra una versión defectuosa.
Cómo revisar la salida: pedí que nombre el defecto que cada test detecta y el que no detecta. Después ejecutá el test contra una versión a propósito defectuosa del adaptador. Si sigue verde, el test no defiende lo que dice. Revisá también que no introduzca un mock de stock para el riesgo de concurrencia.
Comprobá tu criterio
Dos tests secuenciales reservan stock y pasan. ¿Demuestran que no hay sobreventa concurrente?
Para llevarte
- Un test de error tiene que verificar lo que no debe existir después de la operación. El status HTTP es la parte más pequeña del oráculo.
- Una carrera se prueba con dos conexiones reales, una barrera y un oráculo que mira el estado final. Un fake o una ejecución secuencial no demuestran nada.
- Antes de confiar en un test, ejecutalo contra una versión defectuosa. Si sigue verde, el oráculo es débil.
- La idempotencia tiene dos direcciones: el reintento con el mismo contenido no duplica, y el mismo requestId con otro contenido es un conflicto sin efectos.
- Un contrato con un tercero define qué hacer ante un timeout con efecto posible. Ese es el caso que más se pasa por alto.
- Lo que no se puede probar todavía (TTL, tolerancia Platino, precios sin vigencia) se registra como BLOCKED con responsable. No se codifica una cifra inventada.
¿Cómo valoras el contenido técnico de esta lección?
Tu feedback nos ayuda a mejorar los laboratorios prácticos de Eje Z.