DevOps from Zero to Hero: Testing Automatizado
Apoya este blog
Si te resulta util este contenido, considera apoyar el blog.
Introduccion
Bienvenido al cuarto articulo de la serie DevOps from Zero to Hero. En los articulos anteriores cubrimos los fundamentos de Linux, networking y control de versiones con Git. Ahora es momento de hablar sobre algo que separa los proyectos hobby del software listo para produccion: el testing automatizado.
Si alguna vez pusheaste un cambio a produccion e inmediatamente te arrepentiste, ya entendes por que el testing importa. Los tests automatizados te dan confianza en que tu codigo funciona como se espera antes de que llegue a los usuarios. En un contexto DevOps, los tests son la puerta entre “codigo escrito” y “codigo deployado.” Sin ellos, tu pipeline de CI/CD es simplemente una forma rapida de enviar bugs.
En este articulo vamos a cubrir la piramide de testing, escribir tests unitarios y de integracion reales en TypeScript usando Vitest y Supertest, hablar sobre lo que la cobertura realmente significa (y por que perseguir el 100% es una trampa), y sentar las bases para correr tests en CI, que vamos a cubrir en profundidad en el articulo cinco.
Vamos a meternos de lleno.
Por que el testing importa para DevOps
El testing no es solo una preocupacion del desarrollador. En un workflow de DevOps, los tests son la base de todo lo demas que construis. Aca te explico por que:
- Confianza para deployar: Si tus tests pasan, podes deployar sin miedo. Si no pasan, sabes que algo esta roto antes de que los usuarios se enteren.
- Feedback rapido: Un buen suite de tests te dice en minutos si un cambio es seguro. Compara eso con esperar QA manual o enterarte por un reporte de usuario.
- Atrapar regresiones: Codigo que funcionaba ayer se puede romper hoy por un cambio aparentemente no relacionado. Los tests atrapan estas regresiones automaticamente.
- Habilitar automatizacion: Los pipelines de CI/CD dependen de tests. Sin tests automatizados, tu pipeline es solo deployment automatizado de codigo no testeado.
- Documentacion: Tests bien escritos describen lo que tu codigo deberia hacer. Sirven como documentacion viva que se mantiene sincronizada con el comportamiento real.
Pensalo de esta manera: cada test que escribis es un pequenio contrato que dice “este comportamiento debe preservarse.” Cuando alguien cambie el codigo dentro de seis meses, esos contratos atrapan cualquier cosa que se rompa. Eso es increiblemente valioso en un equipo donde multiples personas tocan la misma base de codigo.
La piramide de testing
La piramide de testing es un modelo que te ayuda a decidir cuantos tests de cada tipo escribir. Se ve asi:
/ E2E \ Pocos, lentos, caros
/----------\
/ Integracion \ Algunos, velocidad moderada
/----------------\
/ Tests Unitarios \ Muchos, rapidos, baratos
/____________________\
La forma importa. Aca te explico por que:
- Tests unitarios (base de la piramide): Testean funciones o modulos individuales de forma aislada. Son rapidos, baratos de escribir y baratos de correr. Deberias tener la mayor cantidad de estos.
- Tests de integracion (medio): Testean como funcionan multiples piezas juntas, como un endpoint de API que consulta una base de datos. Son mas lentos y complejos, pero atrapan problemas que los tests unitarios no detectan.
- Tests end-to-end (cima): Testean la aplicacion completa desde la perspectiva del usuario, generalmente a traves de un navegador. Son los mas lentos, fragiles y caros de mantener. Deberias tener la menor cantidad de estos.
La forma de piramide existe por un tradeoff entre velocidad y confianza. Los tests unitarios corren en milisegundos pero solo testean piezas pequenias. Los tests E2E toman segundos o minutos pero testean el flujo completo. Si invertis la piramide (muchos E2E, pocos unitarios), tu suite de tests se vuelve lento, fragil y doloroso de mantener.
Una proporcion saludable podria verse algo asi como 70% unitarios, 20% integracion, 10% E2E. Estos numeros no son reglas, son guias. La clave es: empuja el testing hacia el nivel mas bajo que te de confianza. Si podes atrapar un bug con un test unitario, no escribas un test E2E para eso.
Configurando el proyecto
Vamos a armar un pequenio proyecto TypeScript con tests. Vamos a usar Vitest como nuestro test runner porque es rapido, moderno y funciona genial con TypeScript sin configuracion extra.
Primero, inicializa el proyecto:
mkdir testing-demo && cd testing-demo
npm init -y
npm install -D typescript vitest @types/node
npm install express
npm install -D @types/express supertest @types/supertest
Crea un tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Agrega el script de test al package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
Tests unitarios con Vitest
Empecemos por la base de la piramide. Los tests unitarios verifican que las funciones individuales hagan lo que se supone que deben hacer. Deben ser rapidos, aislados y deterministas.
Aca hay un modulo de utilidades simple en src/utils.ts:
// src/utils.ts
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(" ");
if (lastSpace > 0) {
return truncated.slice(0, lastSpace) + "...";
}
return truncated + "...";
}
export function parseQueryParams(query: string): Record<string, string> {
if (!query || query.trim() === "") {
return {};
}
const cleaned = query.startsWith("?") ? query.slice(1) : query;
return cleaned.split("&").reduce(
(params, pair) => {
const [key, value] = pair.split("=");
if (key) {
params[decodeURIComponent(key)] = decodeURIComponent(value ?? "");
}
return params;
},
{} as Record<string, string>,
);
}
Ahora escribamos los tests en src/utils.test.ts:
// src/utils.test.ts
import { describe, it, expect } from "vitest";
import { slugify, truncate, parseQueryParams } from "./utils";
describe("slugify", () => {
it("converts a simple string to a slug", () => {
expect(slugify("Hello World")).toBe("hello-world");
});
it("handles special characters", () => {
expect(slugify("Hello, World! How's it going?")).toBe(
"hello-world-hows-it-going",
);
});
it("collapses multiple spaces and dashes", () => {
expect(slugify("too many spaces")).toBe("too-many-spaces");
expect(slugify("too---many---dashes")).toBe("too-many-dashes");
});
it("trims leading and trailing dashes", () => {
expect(slugify(" -hello- ")).toBe("hello");
});
it("handles empty string", () => {
expect(slugify("")).toBe("");
});
});
describe("truncate", () => {
it("returns the full string if it is shorter than maxLength", () => {
expect(truncate("short", 10)).toBe("short");
});
it("returns the full string if it equals maxLength", () => {
expect(truncate("exact", 5)).toBe("exact");
});
it("truncates at the last space before maxLength", () => {
expect(truncate("this is a longer sentence", 15)).toBe("this is a...");
});
it("truncates without space if no space is found", () => {
expect(truncate("superlongwordwithoutspaces", 10)).toBe(
"superlongw...",
);
});
});
describe("parseQueryParams", () => {
it("parses a simple query string", () => {
expect(parseQueryParams("name=alice&age=30")).toEqual({
name: "alice",
age: "30",
});
});
it("handles a leading question mark", () => {
expect(parseQueryParams("?foo=bar")).toEqual({ foo: "bar" });
});
it("handles URL-encoded values", () => {
expect(parseQueryParams("msg=hello%20world")).toEqual({
msg: "hello world",
});
});
it("returns an empty object for empty input", () => {
expect(parseQueryParams("")).toEqual({});
});
it("handles keys without values", () => {
expect(parseQueryParams("flag=")).toEqual({ flag: "" });
});
});
Desglosemos la estructura de los tests:
describeagrupa tests relacionados. Pensalo como un encabezado de seccion para un conjunto de comportamientos.itdefine un caso de test individual. El string deberia leerse como una oracion: “it converts a simple string to a slug.”expectes la asercion. Toma un valor y encadena un matcher comotoBe,toEqual,toContain, otoThrow.
Corre los tests:
npx vitest run
# Output:
# ✓ src/utils.test.ts (10 tests) 5ms
# ✓ slugify (5 tests)
# ✓ truncate (4 tests)
# ✓ parseQueryParams (5 tests)
# Test Files 1 passed (1)
# Tests 14 passed (14)
Mockeando dependencias
El codigo del mundo real tiene dependencias: bases de datos, APIs, sistemas de archivos. En los tests unitarios, queres aislar la funcion bajo test reemplazando esas dependencias con sustitutos controlados. Esto se llama mocking.
Aca hay un modulo que depende de una API externa en src/weather.ts:
// src/weather.ts
export interface WeatherData {
city: string;
temperature: number;
description: string;
}
export async function fetchWeather(city: string): Promise<WeatherData> {
const response = await fetch(
`https://api.weather.example.com/v1/current?city=${encodeURIComponent(city)}`,
);
if (!response.ok) {
throw new Error(`Weather API returned ${response.status}`);
}
const data = await response.json();
return {
city: data.location.name,
temperature: data.current.temp_c,
description: data.current.condition.text,
};
}
export function formatWeatherReport(weather: WeatherData): string {
return `${weather.city}: ${weather.temperature}C, ${weather.description}`;
}
Y los tests en src/weather.test.ts:
// src/weather.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fetchWeather, formatWeatherReport } from "./weather";
// Mockear la funcion global fetch
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
beforeEach(() => {
mockFetch.mockReset();
});
describe("fetchWeather", () => {
it("returns parsed weather data on success", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
location: { name: "London" },
current: { temp_c: 15, condition: { text: "Partly cloudy" } },
}),
});
const result = await fetchWeather("London");
expect(result).toEqual({
city: "London",
temperature: 15,
description: "Partly cloudy",
});
expect(mockFetch).toHaveBeenCalledWith(
"https://api.weather.example.com/v1/current?city=London",
);
});
it("throws on non-ok response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
await expect(fetchWeather("Nowhere")).rejects.toThrow(
"Weather API returned 404",
);
});
});
describe("formatWeatherReport", () => {
it("formats the weather data as a readable string", () => {
const weather = {
city: "Berlin",
temperature: 22,
description: "Sunny",
};
expect(formatWeatherReport(weather)).toBe("Berlin: 22C, Sunny");
});
});
Conceptos clave de mocking:
vi.fn()crea una funcion mock que registra como fue llamada.vi.stubGlobal()reemplaza un global comofetchcon tu mock.mockResolvedValueOnce()le dice al mock que devolver la proxima vez que sea llamado.mockReset()limpia el estado del mock entre tests para que no se filtren entre si.
Lo importante de entender sobre mocking es esto: no estas testeando fetch. Estas testeando que tu
codigo maneje correctamente la respuesta de fetch. El mock te deja simular diferentes escenarios
(exito, error, timeout) sin hacer llamadas de red reales.
Tests de integracion con Supertest
Los tests de integracion verifican que multiples piezas de tu aplicacion funcionen juntas. Para aplicaciones web, el test de integracion mas comun es pegarle a un endpoint de API y verificar la respuesta.
Aca hay una app Express simple en src/app.ts:
// src/app.ts
import express from "express";
import { slugify, truncate } from "./utils";
export const app = express();
app.use(express.json());
interface Article {
id: number;
title: string;
slug: string;
content: string;
summary?: string;
}
const articles: Article[] = [];
let nextId = 1;
app.get("/api/articles", (_req, res) => {
res.json(articles);
});
app.get("/api/articles/:slug", (req, res) => {
const article = articles.find((a) => a.slug === req.params.slug);
if (!article) {
res.status(404).json({ error: "Article not found" });
return;
}
res.json(article);
});
app.post("/api/articles", (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
res.status(400).json({ error: "Title and content are required" });
return;
}
const article: Article = {
id: nextId++,
title,
slug: slugify(title),
content,
summary: truncate(content, 100),
};
articles.push(article);
res.status(201).json(article);
});
app.delete("/api/articles/:slug", (req, res) => {
const index = articles.findIndex((a) => a.slug === req.params.slug);
if (index === -1) {
res.status(404).json({ error: "Article not found" });
return;
}
articles.splice(index, 1);
res.status(204).send();
});
Ahora los tests de integracion en src/app.test.ts:
// src/app.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import request from "supertest";
import { app } from "./app";
describe("Articles API", () => {
describe("POST /api/articles", () => {
it("creates a new article", async () => {
const response = await request(app)
.post("/api/articles")
.send({
title: "My First Post",
content:
"This is the content of my first blog post. It has enough words to test truncation properly.",
})
.expect(201);
expect(response.body).toMatchObject({
title: "My First Post",
slug: "my-first-post",
content:
"This is the content of my first blog post. It has enough words to test truncation properly.",
});
expect(response.body.id).toBeDefined();
expect(response.body.summary).toBeDefined();
});
it("returns 400 when title is missing", async () => {
const response = await request(app)
.post("/api/articles")
.send({ content: "some content" })
.expect(400);
expect(response.body.error).toBe("Title and content are required");
});
it("returns 400 when content is missing", async () => {
const response = await request(app)
.post("/api/articles")
.send({ title: "A Title" })
.expect(400);
expect(response.body.error).toBe("Title and content are required");
});
});
describe("GET /api/articles", () => {
it("returns the list of articles", async () => {
const response = await request(app).get("/api/articles").expect(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
});
});
describe("GET /api/articles/:slug", () => {
it("returns an article by slug", async () => {
const response = await request(app)
.get("/api/articles/my-first-post")
.expect(200);
expect(response.body.slug).toBe("my-first-post");
expect(response.body.title).toBe("My First Post");
});
it("returns 404 for a non-existent slug", async () => {
const response = await request(app)
.get("/api/articles/does-not-exist")
.expect(404);
expect(response.body.error).toBe("Article not found");
});
});
describe("DELETE /api/articles/:slug", () => {
it("deletes an article by slug", async () => {
await request(app)
.post("/api/articles")
.send({ title: "To Be Deleted", content: "This will be removed" });
await request(app)
.delete("/api/articles/to-be-deleted")
.expect(204);
await request(app)
.get("/api/articles/to-be-deleted")
.expect(404);
});
it("returns 404 when deleting a non-existent article", async () => {
await request(app)
.delete("/api/articles/ghost-article")
.expect(404);
});
});
});
Nota como los tests de integracion difieren de los unitarios:
- Testean el ciclo completo de request/response, no solo una funcion.
- Ejercitan multiples capas (routing, validacion, logica de negocio) juntas.
- Son mas lentos porque levantan la capa HTTP, pero atrapan bugs que los tests unitarios no pueden, como definiciones de rutas incorrectas o middleware faltante.
Supertest es excelente porque no necesita que levantes el servidor en un puerto. Se engancha directamente en Express, asi que los tests son rapidos y no entran en conflicto entre si.
Testeando contra bases de datos reales con Testcontainers
Para aplicaciones que usan base de datos, tenes que decidir: mockeas la base de datos o usas una real? Mockear es mas rapido pero puede esconder bugs relacionados con sintaxis SQL, constraints o comportamiento de queries. Testcontainers te da lo mejor de los dos mundos levantando una base de datos real en Docker para tus tests.
Asi se ve usar Testcontainers conceptualmente:
// src/db.integration.test.ts (ejemplo conceptual)
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { Client } from "pg";
describe("Database integration", () => {
let container: any;
let client: Client;
beforeAll(async () => {
// Levantar un contenedor real de PostgreSQL
container = await new PostgreSqlContainer("postgres:16")
.withDatabase("testdb")
.start();
client = new Client({
connectionString: container.getConnectionUri(),
});
await client.connect();
// Correr migraciones
await client.query(`
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
}, 60000); // Los contenedores pueden tardar un momento en arrancar
afterAll(async () => {
await client.end();
await container.stop();
});
it("inserts and retrieves an article", async () => {
await client.query(
"INSERT INTO articles (title, slug, content) VALUES ($1, $2, $3)",
["Test Article", "test-article", "Some content"],
);
const result = await client.query(
"SELECT * FROM articles WHERE slug = $1",
["test-article"],
);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].title).toBe("Test Article");
});
});
Testcontainers es especialmente util porque:
- Los tests corren contra el mismo motor de base de datos que usas en produccion, atrapando bugs especificos del driver.
- Cada suite de tests obtiene un contenedor fresco, asi que los tests no interfieren entre si.
- Funciona en CI siempre que Docker este disponible (que generalmente lo esta en GitHub Actions).
- Sin base de datos compartida de tests que acumule datos viejos o cause tests flaky por ejecuciones en paralelo.
El tradeoff es velocidad: arrancar un contenedor toma algunos segundos. Por esta razon, los tests con Testcontainers pertenecen al nivel de integracion, no al nivel unitario.
Convenciones de nombres y organizacion de tests
Como nombras y organizas los tests importa mas de lo que pensarias. En seis meses, cuando un test falle en CI, el nombre del test es lo primero que vas a ver. Un buen nombre te dice exactamente que se rompio sin necesidad de leer el codigo.
Aca van algunas convenciones que funcionan bien:
Organizacion de archivos:
src/
utils.ts
utils.test.ts # Co-ubicado con el archivo fuente
app.ts
app.test.ts
weather.ts
weather.test.ts
Co-ubicar los tests con los archivos fuente hace obvio que archivo cubre cada test. Algunos equipos
prefieren un directorio __tests__ separado, pero la co-ubicacion tiene la ventaja de que cuando
renombras o moves un archivo, el test se mueve con el.
Patrones de nombres:
// Bueno: Describe el comportamiento claramente
describe("slugify", () => {
it("converts spaces to dashes", () => {});
it("removes special characters", () => {});
it("handles empty string", () => {});
});
// Malo: Vago o enfocado en la implementacion
describe("slugify", () => {
it("works", () => {});
it("test 1", () => {});
it("uses regex", () => {}); // a quien le importa la implementacion?
});
El nombre del test deberia responder: “Que comportamiento verifica este test?” Cuando falla, la
salida deberia leerse como un reporte de bug: slugify > removes special characters: FAILED.
Que significa realmente la cobertura
La cobertura de codigo mide que porcentaje de tu codigo se ejecuta cuando corren tus tests. Podes generar un reporte de cobertura con Vitest:
npx vitest run --coverage
Esto te da metricas como:
- Cobertura de lineas: Que porcentaje de lineas fueron ejecutadas?
- Cobertura de ramas: Que porcentaje de caminos if/else fueron tomados?
- Cobertura de funciones: Que porcentaje de funciones fueron llamadas?
- Cobertura de sentencias: Que porcentaje de sentencias fueron ejecutadas?
Un reporte de cobertura podria verse asi:
# ------------------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ------------------|---------|----------|---------|---------|
# src/utils.ts | 100 | 100 | 100 | 100 |
# src/weather.ts | 85 | 75 | 100 | 85 |
# src/app.ts | 92 | 80 | 100 | 92 |
# ------------------|---------|----------|---------|---------|
Por que el 100% de cobertura es una trampa:
La cobertura te dice que codigo fue ejecutado, no que codigo fue testeado correctamente. Considera esto:
// Este test tiene 100% de cobertura de la funcion add
function add(a: number, b: number): number {
return a + b;
}
it("covers the add function", () => {
add(1, 2); // Mira, la llamamos! 100% de cobertura!
// Pero nunca verificamos el resultado...
});
Ese test ejecuta cada linea de add pero no prueba nada. La funcion podria devolver "banana" y
el test seguiria pasando. Cobertura sin aserciones significativas es puro teatro.
Que metricas mirar en su lugar:
- Mutation testing: Herramientas como Stryker modifican tu codigo (cambian
+por-, eliminan condicionales) y verifican si algun test falla. Si una mutacion sobrevive, tus tests tienen un punto ciego. Esto es mucho mas significativo que la cobertura de lineas.- Cobertura de ramas sobre cobertura de lineas: La cobertura de ramas atrapa caminos condicionales no testeados. Una funcion con un if/else puede tener 100% de cobertura de lineas pero solo 50% de cobertura de ramas si nunca testeas el camino else.
- Tasa de falla de tests en CI: Si los tests nunca fallan, puede que no esten testeando nada significativo. Si fallan constantemente, puede que sean flaky. Un suite de tests saludable falla ocasionalmente cuando se introducen bugs reales.
- Tiempo de deteccion: Que tan rapido los tests atrapan un bug real despues de que se introduce? Esta es la metrica que realmente importa para DevOps.
Un objetivo razonable de cobertura esta en algun lugar entre 70% y 90%. Cualquier cosa arriba de 90% generalmente significa que estas escribiendo tests para codigo trivial solo para alcanzar un numero.
Cuando NO escribir tests
Testear todo no es el objetivo. Testear las cosas correctas si lo es. Aca hay casos donde escribir tests agrega costo sin valor significativo:
- Codigo generado: Si una herramienta genera tu cliente de API, modelos de ORM o tipos de GraphQL, no testees la salida generada. Testea el codigo que los usa.
- Getters y setters simples: Una funcion que solo devuelve una propiedad no necesita test. Si sentis la necesidad de testearla, la funcion probablemente es demasiado simple para romperse.
- Internos del framework: No testees que Express rutea requests o que React renderiza componentes. Ese es el trabajo del framework. Testea tu logica que corre dentro del framework.
- Librerias de terceros: No testees que
lodash.groupByfunciona correctamente. Los mantenedores de la libreria ya lo hicieron.- Archivos de configuracion: Configs JSON, listados de variables de entorno y datos estaticos no necesitan tests unitarios.
Enfoca tu esfuerzo de testing donde los bugs son mas probables y mas caros: logica de negocio, transformaciones de datos, edge cases en parsing y puntos de integracion entre sistemas.
Corriendo tests en CI
Vamos a cubrir CI/CD en detalle en el proximo articulo, pero aca hay un preview de como se ve correr tests en GitHub Actions:
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- run: npm test
- run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Este workflow corre en cada push y pull request. Si algun test falla, la ejecucion de CI falla y el PR no se puede mergear (asumiendo que tenes branch protection habilitado). Esta es la puerta de la que hablamos antes: el codigo no llega a produccion a menos que pase los tests.
Cosas clave para notar:
npm cien vez denpm install: instala versiones exactas delpackage-lock.json, asegurando builds reproducibles.- Pasos separados de test y coverage: corre los tests primero para feedback rapido, despues coverage como paso separado.
- Subir artifacts: los reportes de cobertura se guardan para que puedas descargarlos y revisarlos despues.
Vamos a expandir esto significativamente en el articulo cinco, cubriendo caching, matrix builds, ejecucion paralela de tests, y mas.
Juntando todo
Repasemos como se ve un proyecto bien testeado. Aca esta la estructura completa de directorios:
testing-demo/
package.json
tsconfig.json
src/
utils.ts # Funciones de utilidad puras
utils.test.ts # Tests unitarios para utils
weather.ts # Modulo con dependencia externa
weather.test.ts # Tests unitarios con mocks
app.ts # Aplicacion Express
app.test.ts # Tests de integracion con Supertest
Cada capa de la piramide esta cubierta:
- Tests unitarios (
utils.test.ts,weather.test.ts): Rapidos, aislados, sin dependencias externas. Estos atrapan bugs de logica.- Tests de integracion (
app.test.ts): Testean la capa HTTP de punta a punta (dentro de la app). Estos atrapan bugs de cableado.- Tests E2E (no mostrados aca): Usarian una herramienta como Playwright o Cypress para testear el stack completo a traves de un navegador.
El workflow de testing en un pipeline de DevOps se ve asi:
- El desarrollador pushea codigo.
- CI corre tests unitarios (segundos).
- CI corre tests de integracion (segundos a minutos).
- CI corre tests E2E (minutos).
- Si todos pasan, el codigo es elegible para deployment.
- Si alguno falla, el pipeline se detiene y se notifica al desarrollador.
Este es el loop de feedback rapido que hace que DevOps funcione. Encontras bugs en minutos, no en dias.
Notas de cierre
El testing no es opcional en un workflow de DevOps. Es la base que hace posible todo lo demas: integracion continua, deployment continuo y la confianza para enviar cambios multiples veces al dia.
Empeza con tests unitarios. Son los mas baratos y te dan el mayor valor por linea de codigo de test. Agrega tests de integracion para tus endpoints de API y flujos de datos criticos. Usa tests E2E con moderacion para tus journeys de usuario mas importantes.
No persigas numeros de cobertura. Enfocate en testear comportamiento que importa: logica de negocio, edge cases y puntos de integracion. Un test bien ubicado que atrapa un bug real vale mas que cien tests que solo inflan una metrica de cobertura.
En el proximo articulo, vamos a tomar estos tests y conectarlos a un pipeline de CI/CD apropiado con GitHub Actions. Vas a ver como correr tests automaticamente, cachear dependencias para velocidad y configurar branch protection para que codigo no testeado nunca llegue a produccion.
Espero que te haya resultado util y que lo hayas disfrutado, hasta la proxima!
Errata
Si encontras algun error o tenes alguna sugerencia, mandame un mensaje para que se corrija.
Tambien podes revisar el codigo fuente y los cambios en los fuentes aca
$ Comentarios
Online: 0Por favor inicie sesión para poder escribir comentarios.