This document describes the internal design and architectural decisions of the library. For a feature inventory and implementation stats see PROJECT_SUMMARY.md. For usage examples and getting-started instructions see QUICKSTART.md.
| Goal | Approach |
|---|---|
| Swift-first | Protocols, value types, async/await, Sendable |
| Type safety | Swift's type system enforces container configuration correctness |
| Developer experience | Fluent builder API, DSL-style wait strategies |
| Minimal coupling | Two clearly separated modules with a defined boundary |
| Testability | All I/O hidden behind protocols; containers are easy to mock |
The library is split into two Swift Package targets with a strict dependency direction:
Testcontainers ──depends on──► DockerClientSwift ──speaks to──► Docker Engine
(high-level API) (low-level HTTP) (REST API)
DockerClientSwift is a self-contained Docker API client. It knows nothing about test containers, wait strategies, or modules. It translates Swift function calls into Docker Engine REST requests over a Unix socket or TCP.
Testcontainers owns all concepts meaningful to test authors: container lifecycle, wait strategies, pre-configured modules, and network management. It delegates all Docker I/O to DockerClientSwift.
┌─────────────────────────────────────────────────────────────────┐
│ Testcontainers module │
│ │
│ ┌─────────────┐ builds ┌──────────────────────────────┐ │
│ │ Modules │──────────► │ ContainerBuilder │ │
│ │ (Postgres, │ │ (fluent config, port binding,│ │
│ │ MySQL, │ │ env vars, wait strategy) │ │
│ │ Redis, │ └──────────────┬───────────────┘ │
│ │ Mongo) │ │ builds │
│ └─────────────┘ ▼ │
│ ┌─────────────────────────┐ │
│ │ DockerContainerImpl │ │
│ │ (implements Container │ │
│ │ protocol) │ │
│ └──────────┬──────────────┘ │
│ │ uses │
│ ┌──────────────────┐ │ │
│ │ WaitStrategy │◄──────────────────┤ │
│ │ (protocol + │ │ │
│ │ 7 impls) │ │ │
│ └──────────────────┘ │ │
│ │ │
│ ┌──────────────────┐ │ │
│ │ Network │◄──────────────────┘ │
│ │ (DockerNetwork │ │
│ │ + Builder) │ │
│ └──────────────────┘ │
│ │ delegates all Docker I/O │
└────────────────────┼────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ DockerClientSwift module │
│ │
│ DockerClient ──► Container API ──► Unix socket / TCP │
│ ──► Image API │
│ ──► Network API │
│ ──► System API │
└─────────────────────────────────────────────────────────────────┘
Every major abstraction is a protocol, not a base class:
public protocol Container: AnyObject {
var id: String { get }
func start() async throws
func stop(timeout: Int) async throws
func getMappedPort(_ containerPort: Int) throws -> Int
// ...
}
public protocol WaitStrategy {
func waitUntilReady(container: any Container, client: DockerClient) async throws
}This makes individual components independently testable and replaceable without inheritance hierarchies.
ContainerBuilder accumulates configuration and executes it only when build() / buildAsync() is called. Each configuration method returns Self, enabling chaining:
ContainerBuilder("postgres:15")
.withPortBinding(5432, assignRandomHostPort: true)
.withEnvironment(["POSTGRES_DB": "test"])
.withWaitStrategy(Wait.tcp(port: 5432))
.build()Modules extend this by pre-populating the builder with sensible defaults and then exposing domain-specific methods (withDatabase, withUsername, etc.).
WaitStrategy implementations are composable and interchangeable. The Wait class acts as a DSL factory:
| Strategy | Mechanism |
|---|---|
NoWaitStrategy |
Returns immediately |
HttpWaitStrategy |
Polls HTTP endpoint for 2xx |
TcpWaitStrategy |
Opens TCP connection |
LogWaitStrategy |
Scans container log stream |
ExecWaitStrategy |
Runs a command inside the container |
HealthCheckWaitStrategy |
Reads Docker health-check status |
CombinedWaitStrategy |
Executes a list of strategies sequentially |
Strategies are polled with configurable timeout and backoff — the Container is passed in so strategies can call exec() or getLogs() without coupling to a specific implementation.
DockerContainerImpl and DockerNetworkImpl are classes, not structs. This is intentional:
- A container is a live external resource — shared references to the same object are desirable.
- Lifecycle methods mutate internal state (container ID, mapped ports).
@unchecked Sendableis used on the Docker client because the underlying HTTP session is thread-safe by design.
Each pre-configured module consists of two types:
PostgresContainer — builder, configures defaults, exposes withDatabase() etc.
└─ .start() ──────────► PostgresContainerReference — running container + getConnectionString()
This keeps the configuration phase separate from the runtime phase and prevents calling getConnectionString() before the container has started.
All I/O operations are async throws. The library targets Swift Concurrency (async/await) exclusively — there are no callbacks or Combine publishers.
Thread safety is handled at two levels:
DockerClientSwiftusesAsyncHTTPClient(backed by SwiftNIO) whoseEventLoopmanages I/O concurrency.- The
TestcontainersDockerClientwrapper is@unchecked Sendablebecause the underlying HTTP client is safe to share across tasks.
Callers are responsible for ensuring that container references are not accessed from multiple tasks simultaneously unless those accesses are read-only.
The client attempts endpoints in this order:
/var/run/docker.sock— standard Unix socket~/.docker/run/docker.sock— Docker Desktop on macOSDOCKER_HOSTenvironment variabletcp://localhost:2375— TCP fallback
Once a reachable endpoint is found it is cached for the lifetime of the client.
TestcontainersError is a typed enum so callers can pattern-match specific failure modes:
public enum TestcontainersError: Error {
case dockerNotAvailable
case containerNotFound(id: String)
case waitStrategyFailed(reason: String)
case portMappingFailed(port: Int)
case timeout
case apiError(message: String)
case invalidConfiguration(reason: String)
}When assignRandomHostPort: true is set the Docker Engine allocates a free host port. The mapping is captured after container start by calling the /containers/{id}/json inspect endpoint and stored in the container object. getMappedPort(_:) reads this local cache — it does not make a network call.
Internally ports are keyed as "\(port)/tcp" to match the Docker API format.
Bridge networks are the recommended approach for inter-container communication in tests:
- Each container gets a DNS name equal to its network alias.
- Containers on the same bridge network can reach each other by alias without exposing ports to the host.
NetworkBuildercreates an isolated bridge andDockerNetworkImplmanages its lifetime.
- Docker Engine API v1.44
- Swift Concurrency — async/await proposal
- testcontainers-dotnet — reference architecture
- docker-client-swift — origin of DockerClientSwift module