Skip to main content
Back to Blog
Guide
2026-05-01

Testcontainers MySQL Node.js — Integration Testing Guide 2026

Complete guide to Testcontainers for MySQL in Node.js. Real integration tests with Docker, migrations, fixtures, character sets, and CI/CD patterns.

Testcontainers MySQL Node.js Integration Testing Guide

MySQL remains the most-deployed open-source database in the world, powering everything from WordPress to massive transactional systems at Shopify, Uber, and Facebook. When you are building a Node.js application that talks to MySQL, your integration tests must validate against the real engine — its specific quirks around character sets, collation, strict mode, JSON column behavior, and ON DUPLICATE KEY syntax simply cannot be faithfully reproduced by a mock. Testcontainers gives you a real, isolated MySQL instance for every test run, programmatically managed by your test runner, with zero docker-compose overhead.

This guide is a deep, hands-on walkthrough of using Testcontainers with MySQL and Node.js in 2026. We cover installation, container configuration, schema migrations, character set gotchas, transactional rollback, connection pooling for the mysql2 driver, and patterns for testing replication, JSON columns, and stored procedures. Every code sample is a working TypeScript snippet using Vitest, mysql2, and the official @testcontainers/mysql module. Patterns translate cleanly to Jest, Knex, TypeORM, Sequelize, Prisma, and Kysely.


Key Takeaways

  • MySqlContainer provides one-line setup for real MySQL 5.7, 8.0, or 8.4 in tests
  • Character set defaults differ between MySQL versions — always pin to a specific image tag
  • mysql2 is the recommended driver because it supports prepared statements and Promises natively
  • Container reuse reduces local test startup from 8 seconds to under 1 second
  • MariaDbContainer is a drop-in for projects that target MariaDB
  • CI/CD setup is trivial because Docker is available on GitHub Actions ubuntu runners

Why Use Testcontainers for MySQL

The standard alternatives all have severe drawbacks. SQLite-in-memory mocking breaks down the moment you use a MySQL-specific feature: JSON_TABLE, generated columns, full-text indexes, partitioning, or even the simple INSERT ... ON DUPLICATE KEY UPDATE syntax. Shared development databases bleed state, do not parallelize, and corrupt over time. Docker-compose requires a separate startup step and couples test execution to environment setup.

Testcontainers fixes all of this. A fresh, isolated MySQL container is started by your test runner, the connection URI is exposed as a string, and the container is automatically reaped when the test process exits.


Installation

npm install --save-dev testcontainers @testcontainers/mysql
npm install --save-dev vitest mysql2

Verify Docker is running with docker info. If that prints version info, Testcontainers will work.

Configure Vitest with longer timeouts to accommodate container startup:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    testTimeout: 60_000,
    hookTimeout: 60_000,
    pool: 'forks',
  },
});

Your First Test

import { MySqlContainer, StartedMySqlContainer } from '@testcontainers/mysql';
import mysql, { Connection } from 'mysql2/promise';
import { describe, it, beforeAll, afterAll, expect } from 'vitest';

describe('MySQL integration', () => {
  let container: StartedMySqlContainer;
  let conn: Connection;

  beforeAll(async () => {
    container = await new MySqlContainer('mysql:8.4').start();
    conn = await mysql.createConnection({
      host: container.getHost(),
      port: container.getPort(),
      user: container.getUsername(),
      password: container.getUserPassword(),
      database: container.getDatabase(),
    });
  });

  afterAll(async () => {
    await conn.end();
    await container.stop();
  });

  it('runs a query', async () => {
    const [rows] = await conn.query('SELECT 1 + 1 AS sum');
    expect((rows as any)[0].sum).toBe(2);
  });

  it('creates and queries a table', async () => {
    await conn.query(`
      CREATE TABLE users (
        id INT AUTO_INCREMENT PRIMARY KEY,
        email VARCHAR(255) UNIQUE NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    await conn.query("INSERT INTO users (email) VALUES ('bob@example.com')");
    const [rows] = await conn.query('SELECT * FROM users');
    expect((rows as any[]).length).toBe(1);
  });
});

First run pulls the MySQL image (about 500 MB). After that, runs use the cached image and complete in seconds.


MySqlContainer API Reference

MethodPurposeDefault
new MySqlContainer(image)Constructormysql:8.0.31
.withDatabase(name)Override database nametest
.withUsername(name)Override username (not root)test
.withUserPassword(pwd)Override password for the usertest
.withRootPassword(pwd)Override root passwordtest
.withCommand(cmd)Override Docker CMDmysqld default
.withEnvironment(env)Set additional env varsnone
.withReuse()Reuse container across runsdisabled

After start:

MethodReturns
getHost()Hostname
getPort()Mapped host port
getDatabase()Database name
getUsername()Username
getUserPassword()User password
getRootPassword()Root password
getConnectionUri()Full mysql:// connection URI

Character Set and Collation

MySQL's character set defaults have been a source of bugs for two decades. Always force utf8mb4 to support emoji, CJK characters, and the full Unicode range:

container = await new MySqlContainer('mysql:8.4')
  .withCommand(['--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci'])
  .start();

For MySQL 8.0+, the default is already utf8mb4_0900_ai_ci, which is fine for most cases but differs from MySQL 5.7. If your production uses 5.7, test against 5.7.


Running Migrations

With Knex:

import knex from 'knex';

let db: knex.Knex;

beforeAll(async () => {
  container = await new MySqlContainer('mysql:8.4').start();
  db = knex({
    client: 'mysql2',
    connection: {
      host: container.getHost(),
      port: container.getPort(),
      user: container.getUsername(),
      password: container.getUserPassword(),
      database: container.getDatabase(),
    },
  });
  await db.migrate.latest({ directory: './migrations' });
});

With TypeORM:

import { DataSource } from 'typeorm';

let dataSource: DataSource;

beforeAll(async () => {
  container = await new MySqlContainer('mysql:8.4').start();
  dataSource = new DataSource({
    type: 'mysql',
    host: container.getHost(),
    port: container.getPort(),
    username: container.getUsername(),
    password: container.getUserPassword(),
    database: container.getDatabase(),
    entities: [User, Order],
    migrations: ['migrations/*.ts'],
  });
  await dataSource.initialize();
  await dataSource.runMigrations();
});

Transactional Rollback Pattern

The fastest way to isolate tests is to wrap each in a transaction and roll it back:

let conn: Connection;

beforeAll(async () => {
  container = await new MySqlContainer('mysql:8.4').start();
  conn = await mysql.createConnection(container.getConnectionUri());
  await conn.query(`CREATE TABLE accounts (id INT PRIMARY KEY, balance INT)`);
});

beforeEach(async () => {
  await conn.beginTransaction();
});

afterEach(async () => {
  await conn.rollback();
});

it('updates balance', async () => {
  await conn.query('INSERT INTO accounts VALUES (1, 100)');
  await conn.query('UPDATE accounts SET balance = balance - 10 WHERE id = 1');
  const [rows] = await conn.query('SELECT balance FROM accounts WHERE id = 1');
  expect((rows as any)[0].balance).toBe(90);
});

Caveat: rollback does not undo DDL (CREATE TABLE, ALTER TABLE) in MySQL. If your tests issue DDL, use the truncate-and-seed pattern instead.


Connection Pooling

For tests that exercise concurrent code paths, use a pool:

import mysql from 'mysql2/promise';

const pool = mysql.createPool({
  host: container.getHost(),
  port: container.getPort(),
  user: container.getUsername(),
  password: container.getUserPassword(),
  database: container.getDatabase(),
  connectionLimit: 5,
  waitForConnections: true,
});

Keep connectionLimit low (5 to 10). MySQL's default max_connections is 151. If you run 20 test files in parallel and each opens a 20-connection pool, you will exhaust the limit and see "Too many connections" errors.


JSON Column Testing

MySQL 5.7+ supports JSON columns with rich functions. Test these against real MySQL, never a mock:

it('queries JSON column', async () => {
  await conn.query(`
    CREATE TABLE products (
      id INT PRIMARY KEY,
      attributes JSON
    )
  `);
  await conn.query(`
    INSERT INTO products VALUES (1, '{"color": "red", "size": "L"}')
  `);
  const [rows] = await conn.query(`
    SELECT JSON_EXTRACT(attributes, '$.color') AS color FROM products WHERE id = 1
  `);
  expect((rows as any)[0].color).toBe('red');
});

JSON_TABLE (introduced in 8.0) is another area where mocks fall short. Always test the real query against the real engine.


Container Reuse for Local Dev

container = await new MySqlContainer('mysql:8.4')
  .withReuse()
  .start();

Enable in ~/.testcontainers.properties:

testcontainers.reuse.enable=true

First run starts a container in 8 seconds. Subsequent runs find the container by label hash and connect in under 1 second.


CI/CD Configuration

GitHub Actions:

name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test

That is the full configuration. Docker is preinstalled on ubuntu runners.

For GitLab with Docker-in-Docker:

test:
  image: node:20
  services:
    - docker:24-dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
  script:
    - npm ci
    - npm test

Common Pitfalls

Strict mode differences. MySQL 8.0 has strict SQL mode enabled by default; 5.7 had it disabled. Tests that rely on lax behavior (truncated strings, zero dates) will pass on 5.7 and fail on 8.0. Pin to your production version.

Authentication plugin. MySQL 8.0 defaults to caching_sha2_password. Older mysql2 versions did not support it. Use mysql2 v3.0+ or override with --default-authentication-plugin=mysql_native_password.

Time zones. Container time zone defaults to UTC. If your production sets time_zone differently, override it with -e TZ=America/New_York or via SET time_zone after connecting.

Forgotten cleanup. Always await container.stop(). Failing to do so leaks containers, especially in watch mode.


MariaDB Variant

Testcontainers also ships a MariaDB module:

import { MariaDbContainer } from '@testcontainers/mariadb';

const container = await new MariaDbContainer('mariadb:11.4').start();

The API is symmetric to MySqlContainer. Most mysql2 queries work without changes, but mariadb has diverged on JSON, sequence functions, and a few storage engine internals. Test against the engine you deploy.


Conclusion

Testcontainers solves MySQL integration testing in Node.js by giving you a real, isolated database per test run, with one-line setup and zero docker-compose overhead. Pair it with mysql2 (or your ORM of choice), pin your image version, set utf8mb4, and use transactional rollback or per-suite containers for isolation. The result is fast, reliable, deterministic tests that catch real MySQL bugs before they ship.

Browse the QA skills directory for related testing patterns, or compare with our PostgreSQL guide for cross-engine projects.

Testcontainers MySQL Node.js — Integration Testing Guide 2026 | QASkills.sh