Pruebas en CI
En resumen: CI ejecuta las pruebas con cada cambio, en una máquina limpia, y no permite fusionar mientras algo esté en rojo. Una prueba que falla es una señal: un defecto, una regla cambiada, una prueba equivocada o un entorno roto. La más peligrosa es la prueba que falla a veces: la gente se acostumbra a volver a ejecutarla y se pierde el fallo real. La causa casi siempre es algo que la prueba no controla, como la hora.
Problema
Rojo por la noche, verde por la mañana
Todas las noches a las 00:30, hora de Madrid, el servidor ejecuta todas las pruebas. Tres noches seguidas falla la prueba «el código promocional vale en su último día». Por la mañana el programador la vuelve a ejecutar, y pasa.
«Falla a veces, vuelve a ejecutarla» se convirtió en costumbre. Un mes después nadie miraba el informe nocturno. Entonces falló una prueba real de un descuento, y nadie lo vio hasta que se quejó el primer cliente.
La idea
Integración continua
La integración continua (continuous integration, CI) es un acuerdo: con cada cambio, antes de que entre en el código compartido, un servidor toma el código en una máquina limpia, lo compila y ejecuta las pruebas. Si algo está en rojo, el cambio no se fusiona.
Aquí está la configuración para GitHub Actions, una de las herramientas más extendidas. Se ejecuta con cada push y cada solicitud de fusión (pull request):
name: Pruebas
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 # Sin las pruebas de interfaz (marcadas con ui): van aparte, una vez por noche. - run: mvn --batch-mode test -DexcludedGroups=uiname: Pruebas
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x # Sin las pruebas de interfaz (Category=UI): van aparte, una vez por noche. - run: dotnet test --filter "Category!=UI"name: Pruebas
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: python-version: "3.12" - run: pip install -r requirements.txt # Sin las pruebas de interfaz (marcadas con ui): van aparte, una vez por noche. - run: pytest -m "not ui" --junitxml=report.xml && behavename: Pruebas
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci # Las pruebas de interfaz (tests/*.spec.js) no están aquí: van con Playwright aparte, una vez por noche. - run: node --test && npx cucumber-jsLas pruebas de componente, los escenarios y las pruebas de API de este módulo se ejecutan aquí. Las pruebas de interfaz van aparte, una vez por noche contra el entorno de pruebas: necesitan un navegador y una tienda en marcha y son demasiado lentas para cada cambio.
Más precisamente, para curiosos
La configuración se muestra pero no se ejecuta en el curso: necesita GitHub. Otras herramientas extendidas son GitLab CI, Jenkins y Azure Pipelines. La idea y los pasos son los mismos: obtener el código, preparar el lenguaje, ejecutar las pruebas, guardar el informe.
La regla «el rojo detiene la fusión» se configura aparte, en la protección de la rama principal (en GitHub: branch protection, required checks). Sin ella, CI es solo un consejo.
Pruébalo
El camino de un cambio
Ordena lo que pasa desde que se sube el cambio hasta la fusión.
El programador sube el cambio y abre una solicitud de fusión (pull request)Las pruebas de componenteSe compila el códigoLos escenarios y las pruebas de APIEl informe: qué pasó y qué fallóCI toma el código en una máquina limpiaLa fusión se permite solo si todo está en verde
Las pruebas más rápidas van primero: si falla una prueba de componente, no tiene sentido esperar a las más lentas. El informe va antes de la decisión, porque la decisión se toma a partir de él.
La idea
Por qué falla una prueba
El rojo en CI es una señal, no un veredicto. Hay cuatro causas, y cada una necesita algo distinto:
- un defecto en el código: el cambio rompió algo. Se corrige el código. Esta es la regresión para la que existen las pruebas;
- la regla cambió: la prueba enuncia el requisito viejo. La prueba cambia, pero según el requisito nuevo, no «hasta que pase»;
- una prueba equivocada: la prueba comprueba algo que nadie pidió. Se corrige la prueba, y se avisa al autor;
- el entorno: la base de datos no arrancó, la red es lenta. Se mira en la máquina, no en el producto.
Una cosa no es una causa: «vuelve a ejecutarla». Si la prueba pasa al segundo intento sin ningún cambio, el problema sigue ahí.
Pruébalo
¿Qué significa este rojo?
La idea
Una prueba que falla a veces
Aquí está la prueba del principio. Un código promocional vale hasta su último día inclusive. El código pregunta «qué día es hoy» en la zona horaria de la tienda, Madrid:
public boolean isValidToday() { LocalDate today = LocalDate.now(ZoneId.of("Europe/Madrid")); return !today.isAfter(lastDay);}public bool IsValidToday(){ var madrid = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, madrid)); return today <= lastDay;}def is_valid_today(self): today = datetime.now(ZoneInfo("Europe/Madrid")).date() return today <= self.last_dayisValidToday() { const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Madrid" }).format(new Date()); return today <= this.lastDay;}Y la prueba crea un código cuyo último día es «hoy», según el reloj de la máquina:
@Test@DisplayName("vale en su último día")void validOnItsLastDay() { PromoCode code = new PromoCode("OTONO10", LocalDate.now()); assertTrue(code.isValidToday());}[Fact(DisplayName = "vale en su último día")]public void ValidOnItsLastDay(){ var code = new PromoCode("OTONO10", DateOnly.FromDateTime(DateTime.Now)); Assert.True(code.IsValidToday());}def test_valid_on_its_last_day(): code = PromoCode("OTONO10", date.today()) assert code.is_valid_today()test("vale en su último día", () => { const today = new Date().toISOString().slice(0, 10); const code = new PromoCode("OTONO10", today); assert.equal(code.isValidToday(), true);});En un portátil en Madrid los dos «hoy» son el mismo día, y la prueba siempre pasa. El servidor de CI está en la zona horaria UTC. Entre la medianoche y la 1:00 (las 2:00 en verano) hora de Madrid, en Madrid ya es mañana, pero en el servidor todavía es hoy. El código dice: el último día ya pasó. La prueba falla justo cuando se ejecuta la compilación nocturna. (En JavaScript la prueba toma siempre la fecha en UTC, así que a esa hora falla también en el portátil.)
A una prueba así se la llama inestable (flaky): a veces pasa y a veces falla, sin ningún cambio en el código. Las causas casi siempre son algo que la prueba no controla:
- la hora y la fecha, la zona horaria;
- el orden: la prueba depende de datos que dejó otra prueba;
- las esperas: una pausa fija que a veces no basta;
- la red y los servicios externos;
- datos aleatorios sin una semilla fija.
Más precisamente, para curiosos
El código y la prueba de esta tarjeta se muestran y se compilan, pero no se ejecutan en el curso: su resultado depende del momento en que se ejecutan, y una comprobación con una salida esperada no es posible. Ese es justo su problema.
La idea
La solución: el día es una entrada
La prueba no puede controlar el reloj, así que el día se convierte en un parámetro. El código pregunta «¿vale en este día?»:
// El código vale hasta su último día inclusive.public boolean isValidOn(LocalDate day) { return !day.isAfter(lastDay);}// El código vale hasta su último día inclusive.public bool IsValidOn(DateOnly day) => day <= lastDay;def is_valid_on(self, day): """El código vale hasta su último día inclusive.""" return day <= self.last_day// El código vale hasta su último día inclusive. Los días son texto con la forma "2026-11-30".isValidOn(day) { return day <= this.lastDay;}La prueba fija fechas concretas, no «hoy». Y ahora puede comprobar el límite con precisión: el último día y el día siguiente:
@Test@DisplayName("vale en su último día")void validOnItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30)));}[Fact(DisplayName = "vale en su último día")]public void ValidOnItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 30)));def test_valid_on_its_last_day(): assert code.is_valid_on(date(2026, 11, 30))test("vale en su último día", () => { assert.equal(code.isValidOn("2026-11-30"), true);});El archivo entero, ejecutado. Pasa en cada ejecución, en cada máquina, a cada hora:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertTrue;
class PromoCodeTest { private final PromoCode code = new PromoCode("OTONO10", LocalDate.of(2026, 11, 30));
@Test @DisplayName("vale antes de su último día") void validBeforeItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 1))); }
@Test @DisplayName("vale en su último día") void validOnItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30))); }
@Test @DisplayName("no vale al día siguiente") void notValidTheDayAfter() { assertFalse(code.isValidOn(LocalDate.of(2026, 12, 1))); }}OK no vale al día siguienteOK vale antes de su último díaOK vale en su último díaPruebas: 3, pasan: 3, fallan: 0using Xunit;
namespace Shop;
public class PromoCodeTest{ private readonly PromoCode code = new("OTONO10", new DateOnly(2026, 11, 30));
[Fact(DisplayName = "vale antes de su último día")] public void ValidBeforeItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 1)));
[Fact(DisplayName = "vale en su último día")] public void ValidOnItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 30)));
[Fact(DisplayName = "no vale al día siguiente")] public void NotValidTheDayAfter() => Assert.False(code.IsValidOn(new DateOnly(2026, 12, 1)));}OK no vale al día siguienteOK vale antes de su último díaOK vale en su último díaPruebas: 3, pasan: 3, fallan: 0from datetime import date
from promo_code import PromoCode
code = PromoCode("OTONO10", date(2026, 11, 30))
def test_valid_before_its_last_day(): assert code.is_valid_on(date(2026, 11, 1))
def test_valid_on_its_last_day(): assert code.is_valid_on(date(2026, 11, 30))
def test_not_valid_the_day_after(): assert not code.is_valid_on(date(2026, 12, 1))OK test_not_valid_the_day_afterOK test_valid_before_its_last_dayOK test_valid_on_its_last_dayPruebas: 3, pasan: 3, fallan: 0import { test } from "node:test";import assert from "node:assert/strict";import { PromoCode } from "./promo-code.js";
const code = new PromoCode("OTONO10", "2026-11-30");
test("vale antes de su último día", () => { assert.equal(code.isValidOn("2026-11-01"), true);});
test("vale en su último día", () => { assert.equal(code.isValidOn("2026-11-30"), true);});
test("no vale al día siguiente", () => { assert.equal(code.isValidOn("2026-12-01"), false);});OK no vale al día siguienteOK vale antes de su último díaOK vale en su último díaPruebas: 3, pasan: 3, fallan: 0Y si alguien escribe «antes del último día» en lugar de «hasta el último día inclusive», la prueba falla siempre, no a veces:
// El código vale hasta su último día inclusive.public boolean isValidOn(LocalDate day) { return day.isBefore(lastDay);}// El código vale hasta su último día inclusive.public bool IsValidOn(DateOnly day) => day < lastDay;def is_valid_on(self, day): """El código vale hasta su último día inclusive.""" return day < self.last_day// El código vale hasta su último día inclusive. Los días son texto con la forma "2026-11-30".isValidOn(day) { return day < this.lastDay;}package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertTrue;
class PromoCodeTest { private final PromoCode code = new PromoCode("OTONO10", LocalDate.of(2026, 11, 30));
@Test @DisplayName("vale antes de su último día") void validBeforeItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 1))); }
@Test @DisplayName("vale en su último día") void validOnItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30))); }
@Test @DisplayName("no vale al día siguiente") void notValidTheDayAfter() { assertFalse(code.isValidOn(LocalDate.of(2026, 12, 1))); }}FALLA vale en su último día: expected: <true> but was: <false>OK no vale al día siguienteOK vale antes de su último díaPruebas: 3, pasan: 2, fallan: 1using Xunit;
namespace Shop;
public class PromoCodeTest{ private readonly PromoCode code = new("OTONO10", new DateOnly(2026, 11, 30));
[Fact(DisplayName = "vale antes de su último día")] public void ValidBeforeItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 1)));
[Fact(DisplayName = "vale en su último día")] public void ValidOnItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 30)));
[Fact(DisplayName = "no vale al día siguiente")] public void NotValidTheDayAfter() => Assert.False(code.IsValidOn(new DateOnly(2026, 12, 1)));}FALLA vale en su último día: Assert.True() Failure Expected: True Actual: FalseOK no vale al día siguienteOK vale antes de su último díaPruebas: 3, pasan: 2, fallan: 1from datetime import date
from promo_code import PromoCode
code = PromoCode("OTONO10", date(2026, 11, 30))
def test_valid_before_its_last_day(): assert code.is_valid_on(date(2026, 11, 1))
def test_valid_on_its_last_day(): assert code.is_valid_on(date(2026, 11, 30))
def test_not_valid_the_day_after(): assert not code.is_valid_on(date(2026, 12, 1))FALLA test_valid_on_its_last_day: assert FalseOK test_not_valid_the_day_afterOK test_valid_before_its_last_dayPruebas: 3, pasan: 2, fallan: 1import { test } from "node:test";import assert from "node:assert/strict";import { PromoCode } from "./promo-code.js";
const code = new PromoCode("OTONO10", "2026-11-30");
test("vale antes de su último día", () => { assert.equal(code.isValidOn("2026-11-01"), true);});
test("vale en su último día", () => { assert.equal(code.isValidOn("2026-11-30"), true);});
test("no vale al día siguiente", () => { assert.equal(code.isValidOn("2026-12-01"), false);});FALLA vale en su último día: false !== trueOK no vale al día siguienteOK vale antes de su último díaPruebas: 3, pasan: 2, fallan: 1Más precisamente, para curiosos
Java y C# tienen también una manera estándar de pasar el reloj desde fuera: java.time.Clock y TimeProvider (de .NET 8). Entonces el código pregunta al reloj, y la prueba le da uno fijo. En Python y JavaScript hay bibliotecas que congelan el tiempo en una prueba (freezegun, los temporizadores falsos de las herramientas de pruebas). La manera más sencilla, sin embargo, es la de esta tarjeta: el día es un parámetro.
La respuesta a «qué día es hoy para el código promocional» es una decisión del negocio, no del programador. Aquí se decidió: en la zona horaria de la tienda.
La idea
El informe
El informe de CI es lo primero que ve alguien cuando algo está en rojo. Un buen informe dice, sin preguntarle a nadie:
- qué prueba falló, con un nombre que enuncia la regla («vale en su último día»), no
test17; - esperado y real: «esperado: true, real: false»;
- en qué cambio: quién subió qué;
- el historial: si falla por primera vez, o también ayer y anteayer.
Las herramientas escriben el informe en un formato que CI entiende (con más frecuencia JUnit XML), y CI lo muestra junto al cambio.
Si una prueba falla a veces y arreglarla llevará tiempo, lo honesto es apartarla de las pruebas obligatorias (cuarentena), con un informe de defecto y un plazo, y hacer la comprobación a mano. No borrarla, y no ejecutarla hasta que pase.
Ejercicio
Rojo en CI
1. Causas y soluciones. Las preguntas son sobre el código promocional y el CI de la tienda.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Por qué y qué automatizar · Del escenario a los pasos · Pruebas de componentes · API e interfaz de usuario
- Continúa en: Códigos promocionales: de la solicitud al lanzamiento
- Ejercicios sobre el tema: Automatización