VillageSQL is a drop-in replacement for MySQL with extensions.
All examples in this guide work on VillageSQL. Install Now →
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 officialtestcontainers-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
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:database/sql:
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
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:
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 ofdatabase/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:
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:
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 sametestcontainers-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 thanlatest for any test that runs in CI, so a new upstream image release can’t change your test results out from under you:
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. Ifdocker 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 eachTest... 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, amysql: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
WithScriptsin production - MySQL with ORMs — using an ORM’s own connection pool and migration tooling against a Testcontainers-managed database

