StackPractices
beginner Por Mathias Paulenko

Fixture Setup/Teardown: contexto de test reutilizable

Cómo usar fixtures de setup y teardown para crear contexto de test reutilizable. Cubre beforeEach, funciones fábrica, objetos fixture y limpieza con ejemplos.

Temas: testing

Resumen

El patrón fixture setup/teardown define un ciclo de vida para el contexto de cada test: construir lo que el test necesita, ejecutarlo y limpiar después — siempre, falle o no. En vez de duplicar código de inicialización en cada test, escribís el setup una sola vez en un fixture que corre antes de cada test (o suite) y un teardown que corre después. El beneficio es menos código repetido, un estado inicial consistente y nada de conexiones de base de datos, archivos temporales o sockets filtrados al siguiente test.

Cuándo usarlo

  • Tests que comparten la misma inicialización (conexiones de base de datos, datos de prueba, mocks)
  • Tests que deben liberar recursos (archivos temporales, conexiones de red, variables de entorno)
  • Cuando el setup es complejo o propenso a errores — centralizalo en un fixture
  • Cuando los tests deben correr aislados — los fixtures garantizan estado limpio entre tests
  • Equipos que quieren código de test DRY — evitá copiar setup en decenas de tests

Cuándo no usarlo

  • Tests triviales sin setup compartido — el setup inline es más simple
  • Cuando el setup es único para cada test — un fixture agrega indirección sin beneficio
  • Para scripts de un solo uso — los fixtures son para suites estructuradas
  • Cuando el fixture es más complejo que los tests a los que sirve — simplificá

Explicación

El ciclo de vida de un fixture

Todo fixture corre el mismo ciclo de tres fases, una vez por límite de scope:

Mermaid flowchart LR diagram

La garantía que importa está del lado derecho del diagrama: el teardown corre incluso cuando el test falla. Eso es lo que evita que un test roto filtre estado al resto de la suite. Lo que no cubre es un fallo en el propio setup: si el código anterior al yield (o dentro de beforeEach) lanza una excepción, la parte de teardown nunca se ejecuta — todo lo reservado antes de ese punto hay que envolverlo en try/finally o registrarlo con request.addfinalizer() en pytest.

Elegir el scope

Cada cuánto corre un fixture define tanto su coste como su seguridad. El estado mutable pertenece al scope function; el contexto costoso y de solo lectura se puede compartir más amplio.

ScopeCorreSirve paraRiesgo si se abusa
function (default)Una vez por testEstado mutable, filas de DB, archivos temporalesMás lento si el setup es caro
class / moduleUna vez por clase o archivoFixtures compartidos de solo lectura, configLas mutaciones se filtran entre tests del mismo scope
sessionUna vez por suiteContenedores Docker, arranque de app, carga de configTests dependientes del orden; fugas invisibles en CI

En Jest el mismo trade-off se mapea a hooks: beforeEach/afterEach equivale a scope function, beforeAll/afterAll a scope module aproximadamente. JUnit lo refleja con @BeforeEach/@AfterEach (por test) y @BeforeAll/@AfterAll (una vez por clase).

Semántica del teardown por framework

  • pytest: el código después del yield corre aunque el test falle. Si el setup lanza una excepción antes del yield, lo que sigue nunca corre — usá request.addfinalizer() o un try/finally alrededor de la parte riesgosa.
  • Jest: afterEach corre tras el cuerpo del test aunque falle, y también corre si beforeEach lanza un error. afterAll corre igual aunque haya fallado algún test del bloque.
  • JUnit 5: @AfterEach se ejecuta incluso si el test o un @BeforeEach falla. @AfterAll corre cuando la clase termina, sin importar los resultados individuales.

Fixtures anidados y compuestos

Los fixtures se componen como una pila: el setup se resuelve de afuera hacia adentro, el teardown se desenrolla de adentro hacia afuera. En el ejemplo de scopes, test_database (module) se crea antes que clean_db (function), y el teardown de clean_db corre antes de que se desmonte el fixture de módulo. En Jest, los describe anidados funcionan igual — los beforeEach internos corren después de los externos, los afterEach internos antes que los externos.

Ese orden es lo que hace segura la composición: un fixture barato de scope function puede mutar uno costoso compartido y resetearlo después, como hace clean_db con truncate_all().

Solución

Versiones ejecutables de cada ejemplo en el repositorio complementario.

Setup/teardown básico en Jest

// JavaScript — setup/teardown en Jest
describe('UserService', () => {
  let service;
  let dbConnection;

  beforeEach(() => {
    // Setup: corre antes de cada test
    dbConnection = createInMemoryDB();
    service = new UserService(dbConnection);
    service.createUser('Alice', 'alice@x.com');
  });

  afterEach(() => {
    // Teardown: corre después de cada test
    dbConnection.close();
    service = null;
  });

  test('finds existing user', () => {
    const user = service.getUserByEmail('alice@x.com');
    expect(user).toBeDefined();
    expect(user.name).toBe('Alice');
  });

  test('rejects duplicate email', () => {
    expect(() => service.createUser('Bob', 'alice@x.com')).toThrow(DuplicateEmailError);
  });
});

Setup/teardown en pytest

# Python — pytest con fixtures
import pytest
from myapp.services import UserService
from myapp.repositories import FakeUserRepository

@pytest.fixture
def repo():
    """Repositorio en memoria nuevo para cada test."""
    return FakeUserRepository()

@pytest.fixture
def service(repo):
    """UserService con un repositorio limpio."""
    return UserService(repo)

@pytest.fixture
def service_with_user(service):
    """UserService con un usuario ya creado."""
    service.create_user("Alice", "alice@x.com")
    return service

class TestUserService:
    def test_create_user(self, service):
        user = service.create_user("Bob", "bob@x.com")
        assert user.name == "Bob"
        assert user.email == "bob@x.com"

    def test_find_user(self, service_with_user):
        user = service_with_user.get_user_by_email("alice@x.com")
        assert user.name == "Alice"

    def test_duplicate_email(self, service_with_user):
        with pytest.raises(DuplicateEmailError):
            service_with_user.create_user("Bob", "alice@x.com")

Setup/teardown con yield (pytest)

# Python — fixture de pytest con yield para el teardown
import pytest
import tempfile
import os

@pytest.fixture
def temp_db():
    """Crea una base SQLite temporal, la cede al test y limpia después."""
    fd, path = tempfile.mkstemp(suffix='.db')
    os.close(fd)
    db = SQLiteDB(path)
    db.initialize_schema()

    yield db  # El test corre aquí

    # Teardown: corre después del test
    db.close()
    os.unlink(path)

@pytest.fixture
def populated_db(temp_db):
    """Base de datos con datos semilla."""
    temp_db.insert('users', {'name': 'Alice', 'email': 'alice@x.com'})
    temp_db.insert('users', {'name': 'Bob', 'email': 'bob@x.com'})
    yield temp_db

def test_user_count(populated_db):
    assert populated_db.count('users') == 2

def test_find_alice(populated_db):
    user = populated_db.find_one('users', {'name': 'Alice'})
    assert user['email'] == 'alice@x.com'

Setup/teardown en JUnit

// Java — setup/teardown en JUnit 5
import org.junit.jupiter.api.*;

class UserServiceTest {

    private UserService service;
    private FakeUserRepository repo;

    @BeforeEach
    void setUp() {
        // Corre antes de cada test
        repo = new FakeUserRepository();
        service = new UserService(repo);
        service.createUser("Alice", "alice@x.com");
    }

    @AfterEach
    void tearDown() {
        // Corre después de cada test
        repo.clear();
        service = null;
    }

    @BeforeAll
    static void setUpAll() {
        // Corre una vez antes de todos los tests de la clase
        System.setProperty("TEST_MODE", "true");
    }

    @AfterAll
    static void tearDownAll() {
        // Corre una vez después de todos los tests de la clase
        System.clearProperty("TEST_MODE");
    }

    @Test
    void testFindUser() {
        User user = service.findByEmail("alice@x.com");
        assertEquals("Alice", user.getName());
    }

    @Test
    void testDuplicateEmail() {
        assertThrows(DuplicateEmailException.class, () -> {
            service.createUser("Bob", "alice@x.com");
        });
    }
}

Patrón función fábrica

// JavaScript — función fábrica para datos de prueba
function createTestUser(overrides = {}) {
  return {
    id: 1,
    name: 'Alice',
    email: 'alice@x.com',
    role: 'member',
    createdAt: new Date('2026-01-01'),
    ...overrides,
  };
}

function createTestOrder(overrides = {}) {
  return {
    id: 100,
    userId: 1,
    items: [
      { productId: 1, quantity: 2, price: 10.0 },
      { productId: 2, quantity: 1, price: 25.0 },
    ],
    status: 'pending',
    total: 45.0,
    ...overrides,
  };
}

describe('OrderService', () => {
  let service;

  beforeEach(() => {
    service = new OrderService(new FakeOrderRepository());
  });

  test('calculates order total', () => {
    const order = createTestOrder({
      items: [
        { productId: 1, quantity: 3, price: 15.0 },
      ],
    });
    const total = service.calculateTotal(order);
    expect(total).toBe(45.0);
  });

  test('rejects empty order', () => {
    const order = createTestOrder({ items: [] });
    expect(() => service.calculateTotal(order)).toThrow(EmptyOrderError);
  });

  test('applies discount for premium user', () => {
    const user = createTestUser({ role: 'premium' });
    const order = createTestOrder({ userId: user.id });
    const total = service.calculateTotal(order, user);
    expect(total).toBe(40.5); // 10% de descuento
  });
});

Patrón objeto fixture

Cuando un test necesita más de una dependencia, agrupalas en un solo objeto. El notifier del ejemplo sustituye a un servicio real — mirá el patrón Test Double para decidir cuándo usar un mock y cuándo un fake.

# Python — objeto fixture que agrupa varias dependencias
@pytest.fixture
def test_context():
    """Agrupa todas las dependencias de test en un objeto."""
    repo = FakeUserRepository()
    service = UserService(repo)
    notifier = MagicMock()
    service.set_notifier(notifier)

    class TestContext:
        def __init__(self, repo, service, notifier):
            self.repo = repo
            self.service = service
            self.notifier = notifier

        def create_user(self, name, email):
            return self.service.create_user(name, email)

        def assert_notified(self, email):
            self.notifier.send.assert_called_once()
            assert self.notifier.send.call_args.kwargs['to'] == email

    return TestContext(repo, service, notifier)

def test_user_creation_notifies(test_context):
    test_context.create_user("Alice", "alice@x.com")
    test_context.assert_notified("alice@x.com")

Fixtures con scope

# Python — scopes de fixtures en pytest
@pytest.fixture(scope="session")
def test_config():
    """Se carga una vez por sesión de tests."""
    return load_config("test.yaml")

@pytest.fixture(scope="module")
def test_database():
    """Una base de datos por módulo de tests."""
    db = TestDatabase()
    db.connect()
    yield db
    db.disconnect()
    db.drop_all_tables()

@pytest.fixture(scope="function")
def clean_db(test_database):
    """Estado limpio por función de test."""
    test_database.truncate_all()
    yield test_database
    test_database.truncate_all()

class TestUserRepository:
    def test_create(self, clean_db):
        clean_db.insert('users', {'name': 'Alice'})
        assert clean_db.count('users') == 1

    def test_count_empty(self, clean_db):
        assert clean_db.count('users') == 0  # El fixture truncó las tablas

Setup/teardown de variables de entorno

# Python — manejar variables de entorno en tests
import os
import pytest

@pytest.fixture
def env_vars():
    """Establece y restaura variables de entorno."""
    original = dict(os.environ)
    os.environ['APP_ENV'] = 'test'
    os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
    os.environ['REDIS_URL'] = 'redis://localhost:6379/15'

    yield os.environ

    # Restaura el entorno original
    os.environ.clear()
    os.environ.update(original)

def test_app_starts_in_test_mode(env_vars):
    assert env_vars['APP_ENV'] == 'test'
    assert env_vars['DATABASE_URL'] == 'sqlite:///:memory:'

Fixtures anidados en Jest

// JavaScript — bloques describe anidados con setup por alcance
describe('OrderService', () => {
  let service;

  beforeEach(() => {
    service = new OrderService();
  });

  describe('with pending order', () => {
    let order;

    beforeEach(() => {
      order = service.createOrder({ userId: 1, total: 100 });
    });

    test('can be cancelled', () => {
      service.cancel(order.id);
      expect(service.getOrder(order.id).status).toBe('cancelled');
    });

    test('can be completed', () => {
      service.complete(order.id);
      expect(service.getOrder(order.id).status).toBe('completed');
    });
  });

  describe('with completed order', () => {
    let order;

    beforeEach(() => {
      order = service.createOrder({ userId: 1, total: 100 });
      service.complete(order.id);
    });

    test('cannot be cancelled', () => {
      expect(() => service.cancel(order.id)).toThrow(InvalidStateError);
    });
  });
});

Variantes

Fixtures parametrizados

Un fixture con params vuelve a correr cada test una vez por valor — la versión a nivel fixture del patrón Test Parametrizado.

# Python — fixture parametrizado para probar varias configuraciones
@pytest.fixture(params=[
    {"engine": "sqlite", "url": "sqlite:///:memory:"},
    {"engine": "postgres", "url": "postgresql://localhost/test"},
    {"engine": "mysql", "url": "mysql://localhost/test"},
])
def db(request):
    config = request.param
    db = Database.connect(config["url"])
    db.create_tables()
    yield db
    db.drop_tables()
    db.close()

def test_user_crud(db):
    db.insert('users', {'name': 'Alice'})
    user = db.find_one('users', {'name': 'Alice'})
    assert user is not None

Fixtures de recursos externos

# Python — fixture para una base de datos de prueba en Docker
import pytest
import subprocess
import time

@pytest.fixture(scope="session")
def postgres_container():
    """Levanta un contenedor PostgreSQL para la sesión de tests."""
    container_id = subprocess.check_output([
        "docker", "run", "-d", "-p", "5432:5432",
        "-e", "POSTGRES_PASSWORD=test",
        "postgres:16-alpine"
    ]).decode().strip()

    # Espera activa hasta que PostgreSQL acepte conexiones — sleep() a secas es inestable en CI
    for _ in range(30):
        ready = subprocess.run(
            ["docker", "exec", container_id, "pg_isready"],
            capture_output=True,
        )
        if ready.returncode == 0:
            break
        time.sleep(1)
    else:
        subprocess.run(["docker", "stop", container_id])
        raise RuntimeError("el contenedor postgres no quedó listo")

    yield {
        "host": "localhost",
        "port": "5432",
        "password": "test",
    }

    # Teardown: detiene y borra el contenedor
    subprocess.run(["docker", "stop", container_id])
    subprocess.run(["docker", "rm", container_id])

Setup basado en snapshots

// JavaScript — guardar y restaurar estado entre tests
describe('Database migrations', () => {
  let db;

  beforeAll(async () => {
    db = await connectToTestDB();
    await db.migrate();
    // Guarda un snapshot del estado limpio
    await db.snapshot('clean');
  });

  afterEach(async () => {
    // Restaura el snapshot limpio después de cada test
    await db.restore('clean');
  });

  afterAll(async () => {
    await db.close();
  });

  test('user table exists', async () => {
    const exists = await db.tableExists('users');
    expect(exists).toBe(true);
  });

  test('can insert user', async () => {
    await db.insert('users', { name: 'Alice' });
    expect(await db.count('users')).toBe(1);
  });

  test('starts with clean state', async () => {
    // Este test fallaría sin la restauración del snapshot
    expect(await db.count('users')).toBe(0);
  });
});

Buenas prácticas

  • Mantené los fixtures chicos y enfocados — un fixture por responsabilidad
  • Usá yield en pytest para teardown garantizado — incluso si el test falla
  • Preferí composición sobre herencia — componé fixtures a partir de fixtures más chicos
  • Nombrá los fixtures descriptivamente — service_with_user es más claro que setup
  • Usá funciones fábrica para datos de prueba — createTestUser(overrides) es flexible
  • Limpiá todos los recursos — conexiones de base de datos, archivos temporales, variables de entorno, sockets
  • Elegí el scope con criterio — session para setup costoso, function para aislamiento
  • No compartas estado entre tests — cada test debería ser independiente
  • Para pytest a escala, la guía de Pytest en producción cubre organización de conftest, markers e integración con CI.

Errores comunes

  • Olvidar el teardown: dejar archivos temporales, conexiones abiertas o variables de entorno modificadas provoca fallos en cadena. Siempre pareá setup con teardown.
  • Compartir fixtures de más: un fixture con scope session que muta estado genera dependencia de orden entre tests. Usá scope function para estado mutable.
  • Fixtures complejos: si un fixture ocupa 50 líneas, está haciendo demasiado. Dividilo en fixtures más chicos y componibles.
  • No resetear mocks: los mocks conservan el historial de llamadas entre tests. Siempre resetealos en afterEach o usá jest.clearAllMocks().
  • Efectos secundarios ocultos: un fixture que modifica estado global (variables de entorno, directorio de trabajo) sin restaurarlo rompe otros tests.

Lectura adicional

  • Fixtures de pytest — referencia oficial (pytest 7/8) sobre scopes, teardown con yield y compartición vía conftest.py.
  • Ciclo de vida de JUnit 5 — semántica de @BeforeEach, @AfterEach, @BeforeAll y @AfterAll.
  • Setup y teardown en Jest — beforeEach, afterEach y hooks por bloques describe (Jest 29/30).
  • Testcontainers — librería para contenedores de base de datos/servicios descartables en tests; maneja el readiness check y la limpieza por vos.

Preguntas frecuentes

¿Qué es un fixture de test?

Un fixture es la lógica reutilizable de setup y teardown para tests. Crea las precondiciones que un test necesita (objetos, datos, conexiones) y limpia después.

¿Cuál es la diferencia entre beforeEach y beforeAll?

beforeEach corre antes de cada test — garantiza aislamiento. beforeAll corre una vez antes de todos los tests del bloque — usalo para setup costoso que no muta.

¿Debería usar fixtures o setup inline?

Usá fixtures cuando el setup se comparte entre varios tests. Usá setup inline cuando es único de un test. No fuerces todo en fixtures.

¿Qué es un fixture con yield en pytest?

Un fixture que usa yield en vez de return. El código antes del yield es setup, el de después es teardown. Así la limpieza está garantizada aunque el test falle.

¿Cómo comparto fixtures entre archivos de test?

En pytest, poné los fixtures compartidos en conftest.py — quedan disponibles automáticamente para todos los tests de ese directorio. En Jest, usá setupFilesAfterEach en la config o importá módulos de setup compartidos.