Las migraciones de bases de datos son operaciones críticas que pueden hacer o romper los despliegues en producción. Como se discute en API Testing Architecture: From Monoliths to Microservices, las pruebas adecuadas de bases de datos son esenciales para mantener la confiabilidad del sistema. Esta guía completa cubre estrategias de pruebas para migraciones de bases de datos usando Flyway y Liquibase, asegurando cambios de esquema seguros y despliegues sin tiempo de inactividad.

Comprendiendo los Desafíos de las Migraciones de Base de Datos

Las migraciones de bases de datos presentan desafíos únicos de prueba:

  • Irreversibilidad: Muchos cambios de esquema no pueden deshacerse fácilmente
  • Preservación de datos: Los datos existentes deben permanecer intactos y accesibles
  • Requisitos de cero inactividad: Los sistemas de producción a menudo no pueden permitirse ventanas de mantenimiento
  • Compatibilidad de versiones: El código de aplicación debe funcionar con múltiples versiones de esquema
  • Impacto en el rendimiento: Las migraciones grandes pueden bloquear tablas y degradar el rendimiento (aprenda más sobre pruebas de rendimiento en Pruebas de Rendimiento de Bases de Datos)

Configuración y Setup de Flyway

Flyway usa migraciones basadas en SQL con control de versiones:

# Instalar Flyway CLI
brew install flyway

# Inicializar proyecto Flyway
flyway init

# Crear estructura de directorio de migración
mkdir -p db/migration

Configuración de Flyway (flyway.conf):

flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=dbuser
flyway.password=dbpassword
flyway.schemas=public
flyway.locations=filesystem:db/migration
flyway.validateOnMigrate=true
flyway.outOfOrder=false
flyway.baselineOnMigrate=true

Ejemplo de Migración (V1__create_users_table.sql):

-- V1__create_users_table.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    username VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);

Pruebas de Migración con Flyway:

// flyway-test.js
const { Client } = require('pg');
const { execSync } = require('child_process');

describe('Migraciones Flyway', () => {
  let client;

  beforeAll(async () => {
    // Limpiar base de datos
    execSync('flyway clean -configFiles=flyway-test.conf');

    client = new Client({
      host: 'localhost',
      port: 5432,
      database: 'test_db',
      user: 'test_user',
      password: 'test_password'
    });

    await client.connect();
  });

  test('debe aplicar migraciones exitosamente', () => {
    const result = execSync('flyway migrate -configFiles=flyway-test.conf');
    expect(result.toString()).toContain('Successfully applied');
  });

  test('debe crear tabla users con esquema correcto', async () => {
    const result = await client.query(`
      SELECT column_name, data_type, is_nullable
      FROM information_schema.columns
      WHERE table_name = 'users'
      ORDER BY ordinal_position
    `);

    const columns = result.rows;

    expect(columns).toContainEqual({
      column_name: 'id',
      data_type: 'integer',
      is_nullable: 'NO'
    });

    expect(columns).toContainEqual({
      column_name: 'email',
      data_type: 'character varying',
      is_nullable: 'NO'
    });
  });

  test('debe tener índices correctos', async () => {
    const result = await client.query(`
      SELECT indexname
      FROM pg_indexes
      WHERE tablename = 'users'
    `);

    const indexNames = result.rows.map(r => r.indexname);

    expect(indexNames).toContain('idx_users_email');
    expect(indexNames).toContain('idx_users_username');
  });

  afterAll(async () => {
    await client.end();
  });
});

Configuración y Setup de Liquibase

Liquibase usa changesets XML, YAML, JSON o SQL:

<!-- liquibase.properties -->
changeLogFile=db/changelog/db.changelog-master.xml
url=jdbc:postgresql://localhost:5432/mydb
username=dbuser
password=dbpassword
driver=org.postgresql.Driver

Ejemplo de Changelog (db.changelog-master.xml):

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
    xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
    http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.0.xsd">

  <changeSet id="1" author="developer">
    <createTable tableName="products">
      <column name="id" type="SERIAL">
        <constraints primaryKey="true" nullable="false"/>
      </column>
      <column name="name" type="VARCHAR(255)">
        <constraints nullable="false"/>
      </column>
      <column name="price" type="DECIMAL(10,2)">
        <constraints nullable="false"/>
      </column>
      <column name="stock" type="INTEGER" defaultValue="0"/>
    </createTable>
  </changeSet>

  <changeSet id="2" author="developer">
    <createIndex tableName="products" indexName="idx_products_name">
      <column name="name"/>
    </createIndex>
  </changeSet>

  <changeSet id="3" author="developer">
    <addColumn tableName="products">
      <column name="category" type="VARCHAR(100)"/>
    </addColumn>
  </changeSet>

</databaseChangeLog>

Pruebas de Liquibase:

// liquibase-test.js
const { execSync } = require('child_process');
const { Client } = require('pg');

describe('Migraciones Liquibase', () => {
  let client;

  beforeAll(async () => {
    client = new Client({
      connectionString: 'postgresql://test_user:test_password@localhost:5432/test_db'
    });

    await client.connect();

    // Limpiar base de datos
    execSync('liquibase dropAll --defaultsFile=liquibase-test.properties');
  });

  test('debe aplicar todos los changesets', () => {
    const result = execSync('liquibase update --defaultsFile=liquibase-test.properties');
    expect(result.toString()).toContain('successfully');
  });

  test('debe rastrear ejecución de changesets', async () => {
    const result = await client.query('SELECT * FROM databasechangelog');

    expect(result.rows.length).toBeGreaterThan(0);
    expect(result.rows[0]).toHaveProperty('id');
    expect(result.rows[0]).toHaveProperty('author');
    expect(result.rows[0]).toHaveProperty('filename');
  });

  test('debe crear tabla products con todas las columnas', async () => {
    const result = await client.query(`
      SELECT column_name
      FROM information_schema.columns
      WHERE table_name = 'products'
    `);

    const columnNames = result.rows.map(r => r.column_name);

    expect(columnNames).toContain('id');
    expect(columnNames).toContain('name');
    expect(columnNames).toContain('price');
    expect(columnNames).toContain('stock');
    expect(columnNames).toContain('category');
  });

  afterAll(async () => {
    await client.end();
  });
});

Estrategias de Pruebas de Rollback

Probar capacidades de rollback es crucial para la seguridad en producción:

Pruebas de Rollback de Flyway

Flyway no soporta rollbacks automáticos, pero puede crear migraciones de deshacer:

-- V2__add_status_column.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';

-- U2__remove_status_column.sql (migración de deshacer)
ALTER TABLE users DROP COLUMN status;

Probando Rollback:

describe('Rollback Flyway', () => {
  test('debe hacer rollback de migración exitosamente', async () => {
    // Aplicar migración
    execSync('flyway migrate -configFiles=flyway-test.conf');

    // Verificar que existe columna
    let result = await client.query(`
      SELECT column_name FROM information_schema.columns
      WHERE table_name = 'users' AND column_name = 'status'
    `);
    expect(result.rows.length).toBe(1);

    // Aplicar migración de deshacer
    execSync('flyway undo -configFiles=flyway-test.conf');

    // Verificar columna removida
    result = await client.query(`
      SELECT column_name FROM information_schema.columns
      WHERE table_name = 'users' AND column_name = 'status'
    `);
    expect(result.rows.length).toBe(0);
  });
});

Pruebas de Rollback de Liquibase

Liquibase tiene soporte incorporado de rollback:

<changeSet id="4" author="developer">
  <addColumn tableName="products">
    <column name="description" type="TEXT"/>
  </addColumn>
  <rollback>
    <dropColumn tableName="products" columnName="description"/>
  </rollback>
</changeSet>

Probando Rollback de Liquibase:

describe('Rollback Liquibase', () => {
  test('debe hacer rollback a versión anterior', async () => {
    // Obtener versión actual
    const versionBefore = await client.query(
      'SELECT COUNT(*) FROM databasechangelog'
    );

    // Aplicar nuevo changeset
    execSync('liquibase update --defaultsFile=liquibase-test.properties');

    // Hacer rollback de un changeset
    execSync('liquibase rollbackCount 1 --defaultsFile=liquibase-test.properties');

    // Verificar rollback
    const versionAfter = await client.query(
      'SELECT COUNT(*) FROM databasechangelog'
    );

    expect(versionAfter.rows[0].count).toBe(versionBefore.rows[0].count);
  });

  test('debe hacer rollback por etiqueta', async () => {
    // Etiquetar estado actual
    execSync('liquibase tag version-1.0 --defaultsFile=liquibase-test.properties');

    // Aplicar más cambios
    execSync('liquibase update --defaultsFile=liquibase-test.properties');

    // Rollback a etiqueta
    execSync('liquibase rollback version-1.0 --defaultsFile=liquibase-test.properties');

    // Verificar que el estado coincide con etiqueta
    const result = await client.query(
      "SELECT tag FROM databasechangelog WHERE tag = 'version-1.0'"
    );

    expect(result.rows.length).toBeGreaterThan(0);
  });
});

Validación de Integridad de Datos

Asegurar que las migraciones preservan y transforman correctamente los datos. Para obtener información más profunda sobre estrategias de pruebas de bases de datos, consulte nuestro artículo Pruebas de Bases de Datos en Profundidad:

// data-integrity-test.js
describe('Integridad de Datos Durante Migración', () => {
  beforeEach(async () => {
    // Insertar datos de prueba
    await client.query(`
      INSERT INTO users (email, username)
      VALUES
        ('user1@example.com', 'user1'),
        ('user2@example.com', 'user2'),
        ('user3@example.com', 'user3')
    `);
  });

  test('debe preservar datos existentes durante migración', async () => {
    const countBefore = await client.query('SELECT COUNT(*) FROM users');

    // Aplicar migración que añade columna
    execSync('flyway migrate -configFiles=flyway-test.conf');

    const countAfter = await client.query('SELECT COUNT(*) FROM users');

    expect(countAfter.rows[0].count).toBe(countBefore.rows[0].count);
  });

  test('debe transformar datos correctamente', async () => {
    // Migración: V3__split_username.sql
    // Divide 'username' en 'first_name' y 'last_name'

    await client.query(`
      UPDATE users SET username = 'John Doe' WHERE email = 'user1@example.com'
    `);

    execSync('flyway migrate -target=3 -configFiles=flyway-test.conf');

    const result = await client.query(`
      SELECT first_name, last_name
      FROM users
      WHERE email = 'user1@example.com'
    `);

    expect(result.rows[0].first_name).toBe('John');
    expect(result.rows[0].last_name).toBe('Doe');
  });

  test('debe manejar valores nulos correctamente', async () => {
    await client.query(`
      INSERT INTO users (email, username) VALUES ('null@example.com', NULL)
    `);

    execSync('flyway migrate -configFiles=flyway-test.conf');

    const result = await client.query(`
      SELECT * FROM users WHERE email = 'null@example.com'
    `);

    expect(result.rows.length).toBe(1);
    expect(result.rows[0].username).toBeNull();
  });

  afterEach(async () => {
    await client.query('TRUNCATE users CASCADE');
  });
});

Probando Migraciones en CI/CD

Integrar pruebas de migración en su pipeline CI/CD. Para una guía completa sobre cómo configurar CI/CD para pruebas, consulte nuestra guía sobre Pipeline CI/CD para Testers:

# .github/workflows/db-migration-test.yml
name: Pruebas de Migración de Base de Datos

on:
  pull_request:
    paths:
      - 'db/migration/**'
      - 'db/changelog/**'

jobs:
  test-migrations:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v3

      - name: Configurar Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Instalar Flyway
        run: |
          wget -qO- https://repo1.maven.org/maven2/org/flywaydb/flyway-commandline/9.16.0/flyway-commandline-9.16.0-linux-x64.tar.gz | tar xvz
          sudo ln -s `pwd`/flyway-9.16.0/flyway /usr/local/bin

      - name: Ejecutar pruebas de migración
        run: |
          npm install
          npm run test:migrations
        env:
          DB_HOST: localhost
          DB_PORT: 5432
          DB_NAME: test_db
          DB_USER: test_user
          DB_PASSWORD: test_password

      - name: Probar rollback
        run: npm run test:rollback

      - name: Validar checksums de migración
        run: flyway validate -configFiles=flyway-test.conf

Patrones de Despliegue sin Tiempo de Inactividad

Patrón Expandir-Contraer

// Fase 1: Expandir - Añadir nueva columna (compatible hacia atrás)
// V4__add_email_verified_column.sql
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;

// Fase 2: Migrar datos
// V5__migrate_email_verification.sql
UPDATE users
SET email_verified = (
  SELECT EXISTS(
    SELECT 1 FROM email_verifications
    WHERE email_verifications.user_id = users.id
  )
);

// Fase 3: Contraer - Eliminar tabla antigua (después del despliegue)
// V6__remove_email_verifications_table.sql
DROP TABLE email_verifications;

Probando Migración sin Tiempo de Inactividad:

describe('Migración sin Tiempo de Inactividad', () => {
  test('aplicación funciona durante fase de expansión', async () => {
    // Aplicar migración de expansión
    execSync('flyway migrate -target=4 -configFiles=flyway-test.conf');

    // Simular código de aplicación antiguo
    await client.query(`
      INSERT INTO users (email, username)
      VALUES ('test@example.com', 'testuser')
    `);

    // Verificar que funciona esquema antiguo y nuevo
    const result = await client.query('SELECT * FROM users WHERE email = $1', [
      'test@example.com'
    ]);

    expect(result.rows[0]).toHaveProperty('email');
    expect(result.rows[0]).toHaveProperty('email_verified');
  });

  test('migración de datos preserva integridad', async () => {
    // Insertar datos de prueba
    await client.query(`
      INSERT INTO users (email, username) VALUES ('user@example.com', 'user')
    `);
    await client.query(`
      INSERT INTO email_verifications (user_id) VALUES (1)
    `);

    // Aplicar migración de datos
    execSync('flyway migrate -target=5 -configFiles=flyway-test.conf');

    const result = await client.query('SELECT email_verified FROM users WHERE id = 1');

    expect(result.rows[0].email_verified).toBe(true);
  });
});

Mejores Prácticas de Pruebas de Migración

Lista de Verificación de Pruebas

  • Probar migraciones en base de datos limpia
  • Probar migraciones en base de datos con datos existentes
  • Verificar que todos los índices se crean correctamente
  • Probar restricciones de claves foráneas
  • Validar valores predeterminados y restricciones
  • Probar procedimientos de rollback
  • Verificar integridad de datos después de migración
  • Probar rendimiento de migraciones de datos grandes
  • Verificar problemas de bloqueo en tablas grandes
  • Probar migraciones en múltiples versiones de base de datos
  • Validar checksums de migración
  • Probar escenarios de migración concurrente

Comparación de Pruebas de Migración

AspectoFlywayLiquibase
Soporte RollbackMigraciones de deshacer manualesRollback incorporado
FormatoArchivos SQLXML, YAML, JSON, SQL
Curva de AprendizajeBaja (SQL familiar)Media (sintaxis XML)
Control de VersionesVersionado basado en archivosIDs de changeset
Lógica CondicionalLimitadaExtensa (precondiciones)
Soporte de BDAmplioAmplio
Generación DiffSolo comercialIncorporado

Conclusión

Las pruebas efectivas de migración de bases de datos requieren estrategias completas que cubran validación de esquema, integridad de datos, procedimientos de rollback y patrones de despliegue sin tiempo de inactividad. Al implementar pruebas exhaustivas con Flyway o Liquibase, integrar pruebas en pipelines CI/CD y seguir las mejores prácticas, puede asegurar cambios de base de datos seguros y confiables.

Conclusiones clave:

  • Siempre probar migraciones en base de datos limpia y con datos existentes
  • Implementar y probar procedimientos de rollback para cada migración
  • Usar patrón expandir-contraer para despliegues sin tiempo de inactividad
  • Validar integridad de datos antes y después de migraciones
  • Probar en múltiples versiones de base de datos
  • Automatizar pruebas de migración en pipelines CI/CD

Las pruebas robustas de migración generan confianza en los cambios de base de datos y previenen incidentes costosos en producción.