Skip to main content

VillageSQL is a drop-in replacement for MySQL with extensions.

All examples in this guide work on VillageSQL. Install Now →
Mocking a database driver lets a test run fast, but it can only return what you told it to return. It cannot enforce a unique constraint, reject a value that violates strict mode, or apply your real schema. Testcontainers starts a real, throwaway MySQL container for the test and removes it afterward, so the test runs against the same server your application runs against in production.

How Testcontainers Fits Into a Test Suite

Testcontainers is a library, not a separate service. It talks to the Docker daemon already running on your machine or CI runner, starts a container before your test body runs, waits until the container is actually ready to accept connections, and tears it down when the test finishes. Each test (or each test file, depending on how you scope it) gets its own container, so tests don’t share state and don’t need a shared test database that someone has to remember to reset. This guide uses Go and the official testcontainers-go MySQL module, because Go’s testing package makes the container lifecycle easy to show in full. Testcontainers has equivalent modules for Java, Python, and Node.js/TypeScript; the setup differs by language, but the pattern (start container, wait for readiness, run schema, run test, tear down) is the same.

Project Setup

This pulls in testcontainers-go itself as a transitive dependency of the MySQL module, along with the go-sql-driver/mysql driver the test uses to actually connect and run queries.

The Schema and the Code Under Test

A minimal schema with a constraint worth testing against:
And a small data access layer with two methods, using the standard library’s database/sql:
Nothing here is aware it is being tested. The test that follows exercises this code exactly as the application would call it.

Starting the Container and Loading the Schema

tcmysql.WithScripts mounts schema.sql into the container’s /docker-entrypoint-initdb.d/ directory, which the official MySQL image runs automatically once, the first time the container starts with an empty data directory. container.ConnectionString returns a ready-to-use DSN with the username, password, host, and mapped port already filled in. The t.Cleanup calls run in last-in-first-out order regardless of whether the test passes or fails, which is what tears the container down: no separate teardown step to forget.

Running a Test Against the Real Database

Running this with go test -v starts a real container, applies the schema, inserts a row through CreateUser, reads it back through GetUserByEmail, and tears the container down:
That run used an already-pulled mysql:8.0.36 image. The first run on a machine that has never pulled that image takes noticeably longer: in one measured run here, the first test in the suite took 25.95 seconds end to end (image pull included), and every test after it in the same go test invocation took 6.8 to 7.5 seconds, because Docker only pulls an image once and Testcontainers starts a fresh container from the already-local image on each subsequent Run call. From “container started” to “container is ready” specifically, the MySQL container in this run took about 5 seconds to accept connections.

Why Not Mock the Database

A mock of database/sql returns whatever you told it to return. It has no constraint engine and no strict mode, so it cannot reproduce what MySQL actually does when your code sends it a value MySQL rejects. Two tests against the real container in this project’s store_test.go demonstrate that directly. The schema declares UNIQUE KEY uq_users_email (email). Inserting the same email twice against the real container fails with a specific, real MySQL error:
This passed against the real container with mysqlErr.Number equal to 1062, the actual ER_DUP_ENTRY code MySQL returns for a unique key violation. A mock would need to be told in advance to return exactly this error in exactly this shape; the real database enforces it because the constraint exists in the schema, not in the test. The age column is TINYINT UNSIGNED, so it cannot hold a negative number. Under MySQL’s default strict mode (the default since MySQL 5.7), the server rejects an out-of-range value rather than silently clamping it to zero:
This also passed against the real container with error 1264, ER_WARN_DATA_OUT_OF_RANGE. Neither of these behaviors comes from application code. Both come from MySQL itself, which is exactly why a database-level mock cannot reproduce them without being hand-coded to fake this one specific case.

Pointing Testcontainers at a VillageSQL Image Instead

VillageSQL speaks the same wire protocol as MySQL, so the same testcontainers-go MySQL module can drive a villagesql/server container instead of a stock mysql one. Doing this required one change. The module’s default readiness check waits for the literal log line port: 3306 MySQL Community Server, which VillageSQL’s startup log never prints; VillageSQL’s own ready line reads ... ready for connections. Version: '8.4.11-villagesql-0.0.6' ... port: 3306 .... Overriding the wait strategy to match that line is enough:
WithOccurrence(2) is needed because the entrypoint script starts a temporary server first, to run the init scripts, then starts the real server the container keeps running. Be aware of what the second occurrence actually is: the log carries three matching lines, because the real server’s startup prints one for the X Plugin and one for mysqld itself. Occurrence 2 is the X Plugin line, which lands a fraction of a millisecond before mysqld announces itself, so waiting on it is close enough in practice. Match "/usr/bin/mysqld: ready for connections" instead if you want the wait pinned to the server line itself, since that string appears exactly twice. With this wait strategy in place, the container started, applied schema.sql, and accepted a connection in 6.81 seconds in a run captured here, and SELECT VERSION() returned 8.4.11-villagesql-0.0.6. Everything else, WithDatabase, WithUsername, WithPassword, WithScripts, and ConnectionString, worked unchanged.

Choosing an Image and Version

Pin an exact tag rather than latest for any test that runs in CI, so a new upstream image release can’t change your test results out from under you:
For VillageSQL, pin the same way once you have a version you want to stay on:
Check docker manifest inspect <image>:<tag> before committing to a tag to confirm it exists and to see which architectures it publishes; both mysql:8.0.36 and villagesql/server:latest published amd64 and arm64 manifests as of this writing, so the same tag runs on Intel and Apple Silicon CI runners without a platform override.

Frequently Asked Questions

Does Testcontainers need a real Docker daemon, or does it work with a mock?

It needs a real Docker daemon reachable from wherever the test runs: Docker Desktop locally, or a Docker-in-Docker or Docker-socket-mounted runner in CI. If docker info fails on the machine running the test, Testcontainers fails to start the container and the test fails with a connection error before it ever reaches your code.

Does each test get its own container, or can tests share one?

That is a choice you make in your test code, not something Testcontainers imposes. Calling the container-starting helper from inside each Test... function, as shown above, gives every test its own container and the strongest isolation. Starting the container once in TestMain and sharing it across tests in a package is faster, but then each test is responsible for cleaning up the rows it inserted so it doesn’t see another test’s data.

How long does a container take to become ready?

In the runs captured for this guide, a mysql:8.0.36 container took about 5 seconds from “container started” to “ready for connections” once the image was already pulled locally, and a villagesql/server:latest container took a comparable amount of time. The first container to start in a go test run that has never pulled the image before takes substantially longer, because it pays for the image pull as well; one measured run here took 25.95 seconds for that first container.

Can I run the schema migration tool I already use instead of a plain SQL file?

Yes. WithScripts just mounts files into /docker-entrypoint-initdb.d/, which accepts .sql and .sh files. A shell script in that directory can invoke any migration tool that can run against a host and port, as long as the tool is available inside the container or you install it as part of the script.

Troubleshooting

See also

  • MySQL on Docker — the image, environment variables, and volume behavior Testcontainers builds on
  • Schema Migrations in MySQL — running the same schema changes this guide loads through WithScripts in production
  • MySQL with ORMs — using an ORM’s own connection pool and migration tooling against a Testcontainers-managed database