Resource library

QA How-To

Testcontainers vs Docker Compose Integration Tests (2026)

Compare testcontainers vs docker compose integration tests with runnable TypeScript, PostgreSQL, lifecycle, isolation, debugging, and CI guidance for teams.

21 min read | 2,812 words

TL;DR

Use Testcontainers as the default for code-owned, isolated integration dependencies. Use Docker Compose for a shared multi-service topology that developers need to inspect or keep running. The winning choice depends on lifecycle ownership, parallel isolation, and debugging needs, not on container count alone.

Key Takeaways

  • Choose Testcontainers when test code should own isolated dependencies and discover random mapped ports automatically.
  • Choose Docker Compose when people and tools need one visible, reusable multi-service environment outside a single test process.
  • Compare both approaches against the same test boundary, image, migration, data reset, and assertion.
  • Use semantic readiness checks, not fixed sleeps or container-running status.
  • Make teardown unconditional and give parallel workers separate projects, schemas, or containers.
  • A hybrid design is valid when Compose starts the application stack and Testcontainers supplies test-specific dependencies.
  • Measure startup, suite duration, failure diagnosis, and leaked resources before standardizing on one approach.

Testcontainers vs docker compose integration tests is fundamentally a lifecycle decision. Choose Testcontainers when the test process should create a disposable dependency, receive its connection details, and destroy it automatically. Choose Docker Compose when the team needs a named, inspectable stack that can exist before and after one test command.

Both can run the same PostgreSQL image and validate the same SQL behavior. The differences are lifecycle ownership, parallel isolation, readiness, and failure evidence. This guide builds both paths around one TypeScript repository test, then gives you a decision framework you can apply to databases, brokers, caches, and service stacks. If the test boundary itself is still unclear, start with the integration testing guide.

TL;DR

Testcontainers is the stronger default for automated suites that need fresh infrastructure per test process. Docker Compose is usually better for an environment that several commands, engineers, or test tools must share. Neither tool makes data isolation, migrations, or assertions correct for you.

Decision factor Testcontainers Docker Compose Better fit
Lifecycle owner Test code CLI, script, or operator Match the actual owner
Port handling Random mapped ports returned by API Often declared host ports Testcontainers for parallel runs
Multi-service topology Built in code or via Compose support Native declarative YAML Compose for a visible stack
Per-run isolation Natural when each process starts containers Requires unique project names and ports Testcontainers
Interactive debugging Container APIs and captured logs Simple ps, logs, and exec commands Compose
Reuse across tools Scoped to the runner unless exported Easy for tests, IDEs, and humans Compose
Cleanup Runner hooks plus resource reaper Explicit down, usually in a trap Testcontainers
Configuration review Test source code Central Compose model Depends on team ownership

A practical rule is: dependency containers belong close to the tests, while a product-like environment belongs in a declarative stack. Use a hybrid when those boundaries overlap.

What You Will Build

You will create one catalog repository and execute the same upsert assertion in two ways:

  • A Vitest suite starts PostgreSQL through the current Node Testcontainers module.
  • A Compose file starts the same PostgreSQL major version with a healthcheck.
  • Both suites run the same migration and call the same repository functions.
  • Each path proves readiness, state reset, and teardown with an explicit command.
  • A CI pattern prevents leaked containers and preserves useful logs.

The example deliberately uses one dependency. That keeps the comparison honest because orchestration complexity cannot hide lifecycle behavior. After it works, extend the design with the Testcontainers integration test tutorial or the Docker Compose test environment guide.

Prerequisites

Use Node.js 22 or newer, npm, TypeScript, a Docker-compatible runtime supported by your Testcontainers library, and the docker compose subcommand. PostgreSQL 18 is used in both paths so a database-version difference cannot influence the result.

Verify the toolchain before creating files:

node --version
npm --version
docker version
docker compose version
docker run --rm postgres:18-alpine postgres --version

The final command proves that the current user can reach the runtime, obtain the image, create a container, and execute it. With a remote or rootless runtime, confirm the CLI and Testcontainers resolve the same endpoint.

Do not begin by increasing timeouts. Authentication failures, unavailable sockets, blocked image pulls, and insufficient disk space need different fixes. The Docker basics for testers guide covers the runtime concepts behind these checks.

Step 1: Define the Integration Boundary Before Choosing a Tool

Write the risk in one sentence: an upsert on an existing SKU must update one PostgreSQL row instead of inserting a duplicate. This requires a real unique constraint and PostgreSQL conflict handling. A mocked database client would verify calls, but not the database behavior that can break production.

Use the same boundary for both candidates:

  1. Start PostgreSQL 18.
  2. Apply a products table migration.
  3. Insert a SKU, then upsert the same SKU with a new name.
  4. Query by SKU and assert the updated value.
  5. Assert that only one row exists.
  6. Clear mutable rows and release every resource.

This definition stops a common comparison error: a narrow Testcontainers repository test gets measured against a Compose stack containing the application, broker, database, mock server, and browser runner. That result measures different scopes, not different orchestration tools.

Verify: save the six checks in the test plan or pull request description, then confirm the risk is database-specific:

printf '%s\n' 'Risk: PostgreSQL upsert keeps one row per SKU'

Expected output is the exact risk statement. It becomes the review standard for every code block that follows.

Step 2: Create the TypeScript Test Project

Create an empty directory, initialize npm, and install the current packages. Commit the generated lockfile in a real project so CI uses the reviewed dependency graph.

mkdir container-integration-comparison
cd container-integration-comparison
npm init -y
npm install pg
npm install --save-dev typescript vitest @types/node @types/pg testcontainers @testcontainers/postgresql
npm pkg set type=module
npm pkg set 'scripts.typecheck=tsc --noEmit'
npm pkg set 'scripts.test:testcontainers=vitest run tests/testcontainers/catalog.test.ts'
npm pkg set 'scripts.test:compose=vitest run tests/compose/catalog.test.ts'

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node", "vitest/globals"]
  },
  "include": ["src/**/*.ts", "tests/**/*.ts"]
}

NodeNext requires relative TypeScript imports to use the emitted .js extension. The test files below follow that rule. Keeping compiler strictness enabled catches optional environment variables and uninitialized lifecycle handles before a container starts.

Verify: inspect the scripts and compiler:

npm pkg get scripts
npm exec tsc -- --version

You should see both test scripts, typecheck, and a TypeScript version. No database is needed for this verification.

Step 3: Build One Repository Contract for Both Approaches

Create src/catalogRepository.ts. Every function accepts a Pool, so neither the product code nor the assertion knows who started PostgreSQL. That separation is central to a fair testcontainers vs docker compose integration tests comparison.

import type { Pool } from "pg";

export type Product = { sku: string; name: string };

export async function migrate(pool: Pool): Promise<void> {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS products (
      id BIGSERIAL PRIMARY KEY,
      sku TEXT NOT NULL UNIQUE,
      name TEXT NOT NULL
    )
  `);
}

export async function saveProduct(pool: Pool, product: Product): Promise<void> {
  await pool.query(
    `INSERT INTO products (sku, name) VALUES ($1, $2)
     ON CONFLICT (sku) DO UPDATE SET name = EXCLUDED.name`,
    [product.sku, product.name],
  );
}

export async function findProduct(pool: Pool, sku: string): Promise<Product | null> {
  const result = await pool.query<Product>(
    "SELECT sku, name FROM products WHERE sku = $1",
    [sku],
  );
  return result.rows[0] ?? null;
}

export async function countProducts(pool: Pool): Promise<number> {
  const result = await pool.query<{ count: string }>("SELECT COUNT(*) AS count FROM products");
  return Number(result.rows[0].count);
}

export async function clearProducts(pool: Pool): Promise<void> {
  await pool.query("TRUNCATE TABLE products RESTART IDENTITY");
}

Parameter binding prevents test data from becoming SQL syntax. ON CONFLICT exercises the exact PostgreSQL behavior in the risk statement. The pool remains outside this module so suite setup owns connections and suite teardown can close them deterministically.

Verify: run static checking:

npm run typecheck

Expected output ends without TypeScript errors. A missing .js extension in later imports will now fail before runtime.

Step 4: Run Testcontainers vs Docker Compose Integration Tests With Test-Owned PostgreSQL

Create tests/testcontainers/catalog.test.ts:

import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { Pool } from "pg";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import {
  clearProducts,
  countProducts,
  findProduct,
  migrate,
  saveProduct,
} from "../../src/catalogRepository.js";

type StartedPostgres = Awaited<ReturnType<PostgreSqlContainer["start"]>>;

let postgres: StartedPostgres;
let pool: Pool;

beforeAll(async () => {
  postgres = await new PostgreSqlContainer("postgres:18-alpine")
    .withDatabase("catalog")
    .withUsername("catalog_user")
    .withPassword("catalog_password")
    .start();

  pool = new Pool({ connectionString: postgres.getConnectionUri() });
  await migrate(pool);
}, 60_000);

afterEach(async () => {
  await clearProducts(pool);
});

afterAll(async () => {
  await pool.end();
  await postgres.stop();
});

test("upsert updates one product row", async () => {
  await saveProduct(pool, { sku: "KB-100", name: "Keyboard" });
  await saveProduct(pool, { sku: "KB-100", name: "Mechanical Keyboard" });

  await expect(findProduct(pool, "KB-100")).resolves.toEqual({
    sku: "KB-100",
    name: "Mechanical Keyboard",
  });
  await expect(countProducts(pool)).resolves.toBe(1);
});

The module starts a fresh database with a random published host port, then getConnectionUri() supplies the actual address. There is no fixed localhost:5432 assumption. The suite applies schema once, truncates rows after each case, closes client connections, and finally stops the container.

For strict isolation, start one container per test. For faster execution, keep one per file and isolate worker data. The ephemeral database seeding tutorial shows how to keep fixtures deterministic.

Verify: run the isolated suite twice:

npm run test:testcontainers
npm run test:testcontainers

Both runs should report one passing test. The second pass demonstrates that the test does not depend on data left by the first process.

Step 5: Define the Same PostgreSQL With Docker Compose

Create compose.integration.yaml:

name: catalog-integration
services:
  postgres:
    image: postgres:18-alpine
    environment:
      POSTGRES_DB: catalog
      POSTGRES_USER: catalog_user
      POSTGRES_PASSWORD: catalog_password
    ports:
      - "${PG_TEST_PORT:-55432}:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 2s
      timeout: 3s
      retries: 15
      start_period: 5s

The healthcheck asks PostgreSQL whether it can accept connections. Container-running status alone is not readiness. No volume is declared, so database files remain in the container writable layer and disappear when Compose removes that container. Add a named volume only when a test genuinely evaluates persistent-volume behavior.

The host port defaults to 55432 because host-side Vitest needs an address. That fixed mapping is convenient for IDEs but can collide when two projects run together. Set a distinct PG_TEST_PORT and Compose project name per worker, or run the test runner as another Compose service and use postgres:5432 inside the project network.

Verify: validate interpolation, start the service, and inspect health:

docker compose -f compose.integration.yaml config -q
docker compose -p catalog-it -f compose.integration.yaml up -d --wait --wait-timeout 60
docker compose -p catalog-it -f compose.integration.yaml ps

ps should show the PostgreSQL service as healthy with host port 55432 mapped to container port 5432. If config -q fails, fix YAML or missing variables before diagnosing PostgreSQL.

Step 6: Execute the Docker Compose Integration Test

Create tests/compose/catalog.test.ts:

import { Pool } from "pg";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import {
  clearProducts,
  countProducts,
  findProduct,
  migrate,
  saveProduct,
} from "../../src/catalogRepository.js";

const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
  throw new Error("DATABASE_URL is required for the Compose integration suite");
}

const pool = new Pool({ connectionString });

beforeAll(async () => {
  await migrate(pool);
});

afterEach(async () => {
  await clearProducts(pool);
});

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

test("upsert updates one product row", async () => {
  await saveProduct(pool, { sku: "KB-100", name: "Keyboard" });
  await saveProduct(pool, { sku: "KB-100", name: "Mechanical Keyboard" });

  await expect(findProduct(pool, "KB-100")).resolves.toEqual({
    sku: "KB-100",
    name: "Mechanical Keyboard",
  });
  await expect(countProducts(pool)).resolves.toBe(1);
});

The assertion is intentionally identical to the Testcontainers suite. Only infrastructure acquisition changed. Vitest owns the pool, while the calling shell owns the Compose project. This division lets another test tool or a developer connect to the same database before teardown. It also means Vitest cannot guarantee that down runs.

Verify: execute the test, then destroy the project:

DATABASE_URL=postgresql://catalog_user:catalog_password@localhost:55432/catalog npm run test:compose
docker compose -p catalog-it -f compose.integration.yaml down -v
docker compose -p catalog-it -f compose.integration.yaml ps --all

Expect one passing test and no remaining project containers. down -v is defensive here even though this file declares no named volume; it also cleans volumes if the stack later changes.

Step 7: Make Lifecycle and Failure Capture Reliable

Manual Compose commands are easy to forget after a red test. Put ownership in a script that always tears down and prints service logs only on failure. Create run-compose-integration.sh:

#!/usr/bin/env bash
set -euo pipefail

compose=(docker compose -p catalog-it -f compose.integration.yaml)
cleanup() { "${compose[@]}" down -v; }
trap cleanup EXIT

"${compose[@]}" up -d --wait --wait-timeout 60
if ! DATABASE_URL=postgresql://catalog_user:catalog_password@localhost:55432/catalog npm run test:compose; then
  "${compose[@]}" logs --no-color
  exit 1
fi

The trap covers test failures and most normal shell exits. CI cancellation can still interrupt cleanup, so use ephemeral runners or a separate always-run cleanup step as another boundary. Keep secrets out of connection strings printed by tracing or logs. The credentials here are disposable local values, not reusable organization secrets.

For Testcontainers, runner hooks already keep lifecycle close to the suite, and its cleanup process protects against abnormal exits. Still close database pools before stopping containers so teardown does not race open clients. Capture relevant container logs when startup fails rather than replacing the readiness signal with sleep 30.

Verify: check shell syntax and run the wrapper:

bash -n run-compose-integration.sh
bash run-compose-integration.sh
docker compose -p catalog-it -f compose.integration.yaml ps --all

The wrapper should pass, and the final command should list no containers.

Step 8: Compare CI, Parallelism, and Debugging With Evidence

Run each approach on the same CI runner class and record median startup, total suite duration, failure logs, and leaked resource count across representative builds. Do not publish one laptop run as a universal benchmark. Image cache state, database migration size, container runtime, architecture, and network policy materially change the result.

Operational question Testcontainers answer Compose answer
How does a worker avoid host-port collision? Consume random mapped ports from the API Assign unique ports or keep tests inside the network
How is the environment named? Container IDs and optional labels Stable project and service names
Where does readiness live? Module or explicit wait strategy Service healthcheck plus --wait
How are logs collected? Container log APIs and runner hooks docker compose logs
What happens after assertion failure? Suite teardown stops owned resources Shell or CI cleanup must call down
Can a developer inspect the environment afterward? Only if lifecycle is deliberately extended Yes, omit teardown during investigation

In CI, keep the same execution path used locally. Grant access to a compatible container runtime, pin reviewed image references, cache carefully, and clean resources. The guide to adding CI to a test framework helps place these commands in a reliable pipeline.

Verify: after either job, inspect only resources labeled for that run or project. For this Compose example:

docker ps -a --filter label=com.docker.compose.project=catalog-it

The table should be empty after cleanup. For Testcontainers, verify the test process exits and no test-owned PostgreSQL container remains.

Which Should You Choose for Testcontainers vs Docker Compose Integration Tests

Choose Testcontainers when test code is the natural owner. Repository and service integration suites benefit from dynamic ports, programmatic configuration, runner-scoped cleanup, and easy per-process isolation. It is especially effective when each test suite needs a database, cache, broker, or mock server configured around its scenario. The cost is that topology moves into test code, and engineers outside that runner may have less visibility into the environment.

Choose Docker Compose when the environment is the shared artifact. A stack containing an API, worker, PostgreSQL, broker, and mock server is easy to review as YAML, start before several test commands, inspect with standard CLI commands, and hand to developers for debugging. Its risk is ambient lifetime: fixed ports collide, state can survive if volumes are retained, and cleanup is external to the assertion process.

Choose a hybrid when Compose defines the product topology but individual tests need isolated extras. Node Testcontainers can start that stack through DockerComposeEnvironment, putting lifecycle in test code without making shared services safe for parallel mutation.

Use these deciding questions:

  • Does one test process own the dependency from creation through teardown? Favor Testcontainers.
  • Must multiple runners or humans use the same environment? Favor Compose.
  • Do parallel workers require collision-free infrastructure without a port allocator? Favor Testcontainers.
  • Is topology readability and manual inspection more important than per-test customization? Favor Compose.
  • Is the existing Compose file already the maintained development contract? Reuse it, then add unique project names and deterministic reset.

Troubleshooting

The test reports connection refused -> For Testcontainers, use getConnectionUri() after start() and never assume the internal port is published unchanged. For host-side Compose tests, use the declared host port. For a test container inside Compose, use postgres:5432, not localhost.

Compose says running, but SQL still fails -> Add a PostgreSQL pg_isready healthcheck and start with up -d --wait. Running describes process state, while healthy describes the readiness contract you supplied.

The second run sees rows from the first -> Confirm afterEach actually executes, remove retained volumes, and decide whether cleanup belongs at row, schema, database, or container level. Do not hide isolation defects by making test names unique forever.

Parallel Compose jobs collide on port 55432 -> Give every job a unique -p project name and host port, or put the runner inside the Compose network so the database port does not need host publication.

Testcontainers cannot discover the runtime -> Compare runtime endpoint variables, socket permissions, and the Docker context used by the CLI. A successful image pull does not help if the Node process resolves a different daemon.

CI hangs during teardown -> Close pools first, set bounded job timeouts, collect logs before removal, and run cleanup in an always-run phase. Inspect stopped containers and networks on persistent agents instead of repeatedly raising suite timeout.

Interview Questions and Answers

Q: What is the main difference between Testcontainers and Docker Compose for integration testing?

Testcontainers makes the test process the lifecycle owner and returns runtime connection details to code. Compose makes a declarative project the lifecycle unit and expects a CLI, script, or operator to start and stop it.

Q: Why are random ports valuable?

They allow concurrent test processes to publish the same container port without competing for one host port. The test must consume the mapped address from the container API rather than encode a localhost assumption.

Q: How do you prevent startup races?

Wait for behavior that proves readiness. A PostgreSQL module can supply its service-aware wait, while Compose can use pg_isready as a healthcheck and up --wait. Fixed sleeps neither diagnose failure nor adapt to runner speed.

Q: Can Testcontainers start Docker Compose?

Yes. The Node library exposes DockerComposeEnvironment, which can bring up a Compose file, apply wait strategies, access started containers, and take the environment down. Use it when the Compose model is valuable but test code should own lifecycle.

Q: How would you support parallel Compose integration tests?

Assign each worker a unique project name, avoid shared named volumes, and prevent host-port collisions with unique mappings or an internal test-runner service. Data also needs worker-specific schemas, databases, or immutable fixtures.

Q: Which approach is faster?

There is no honest universal winner. Measure the same image, migration, scope, cache state, and runner. Reusing a Compose stack can improve a developer loop, while isolated Testcontainers can reduce collision and cleanup costs that otherwise appear as flakes.

Common Mistakes

  • Comparing different scopes, such as one Testcontainers database against a five-service Compose environment.
  • Treating depends_on or process-running state as proof that a service accepts real requests.
  • Hard-coding a Testcontainers host port instead of reading the mapped address.
  • Using localhost for communication between Compose services.
  • Retaining database volumes while claiming every test run starts clean.
  • Starting Compose in CI without an unconditional log and teardown path.
  • Sharing one mutable database across parallel workers that truncate each other's rows.
  • Applying toy DDL in tests while production uses an untested migration chain.
  • Pinning the library but allowing an unrelated floating image to change underneath it.
  • Logging real credentials through shell tracing or failure diagnostics.
  • Reusing containers as a correctness requirement instead of an optional local optimization.
  • Choosing a tool by service count alone and ignoring who needs to inspect or reuse the stack.

Where To Go Next

First, run both suites exactly as shown and capture their lifecycle evidence. Then expand only the path that matches ownership in your project. Add production migrations, a failure-log policy, and one parallel execution test before standardizing the pattern across repositories.

Use Testcontainers for integration tests when the test runner should own more dependency modules. Use Docker Compose for test environments when you need application services, mocks, and infrastructure in one visible topology. For repeatable data, continue with seeding ephemeral Testcontainers databases. For pipeline adoption, follow adding CI to a test framework.

Conclusion

The answer to testcontainers vs docker compose integration tests is not that one container tool replaces the other. Testcontainers excels at code-owned, isolated dependencies with dynamic runtime details. Docker Compose excels at a reusable, inspectable environment whose topology is shared beyond one test process.

Keep the test risk identical, make readiness semantic, isolate parallel state, and guarantee teardown. Once you measure those properties on your actual suite, the correct choice becomes much clearer than a feature checklist.

Interview Questions and Answers

How do Testcontainers and Docker Compose differ in lifecycle ownership?

Testcontainers puts creation, configuration, connection discovery, and teardown in test code. Compose treats the project as an external lifecycle controlled by CLI commands or scripts. I choose the model that matches who must use and clean the environment.

How would you make a fair performance comparison between them?

I would hold image versions, migrations, test scope, runtime, runner class, and cache state constant. I would record startup and total duration across representative runs, plus failure diagnosis time and leaked resources. One warm laptop run is not sufficient evidence.

How do you eliminate readiness flakes in containerized integration tests?

I wait for service behavior, not a fixed delay. With Testcontainers I use the module wait behavior or an explicit strategy. With Compose I define a healthcheck and use a command that waits for healthy services.

How do you isolate parallel PostgreSQL integration tests?

I give workers separate containers, databases, or schemas and ensure cleanup cannot affect another worker. Testcontainers naturally avoids published-port collisions through dynamic mapping. Compose workers need unique project names plus nonconflicting ports or an internal runner.

What evidence do you preserve when a container integration test fails?

I preserve the root exception, container or Compose service logs, image reference, health state, runtime details, and test report. I collect logs before teardown and redact credentials. This separates product failures from startup, networking, and environment failures.

When would you choose Docker Compose over Testcontainers?

I choose Compose when a stable multi-service topology must be used by developers, IDE-launched apps, exploratory tools, and multiple suites. Its declarative model and standard CLI make inspection easy, provided project naming, readiness, reset, and teardown are automated.

Can a team use both approaches without duplicating tests?

Yes. Keep application and repository code independent of orchestration, then inject connection details from either a started container or a Compose environment. Share migrations, fixtures, and assertions, while keeping separate lifecycle adapters.

Frequently Asked Questions

Is Testcontainers better than Docker Compose for integration tests?

Testcontainers is usually better when one test process should own disposable dependencies and parallel runs need dynamic ports. Docker Compose is usually better when several tools or engineers must share and inspect the same multi-service environment.

Can Testcontainers use an existing Docker Compose file?

Yes. Testcontainers for Node provides DockerComposeEnvironment to start a Compose project, wait for services, access its containers, and shut it down. This keeps the Compose topology while moving lifecycle ownership into test code.

How do I run Docker Compose integration tests in parallel?

Give each worker a unique Compose project name and isolated data. Avoid shared host ports by assigning unique mappings or running the test service inside the Compose network.

Should integration tests reuse containers?

Reuse can shorten a local feedback loop, but correctness must not depend on preserved state. CI should start from deterministic schema and data, and each parallel owner needs an isolation boundary.

How should tests wait for a Compose database?

Define a semantic healthcheck such as pg_isready for PostgreSQL, then use docker compose up with the wait option. Starting order and running status alone do not prove that queries will succeed.

Do Testcontainers integration tests require Docker?

They require a compatible container runtime that the selected Testcontainers implementation can discover and control. Validate runtime access from the same user and environment that executes the test process.

When should I combine Testcontainers and Docker Compose?

Use a hybrid when Compose is the maintained product topology but the suite should own its startup, or when Compose runs the application while individual tests create isolated supporting dependencies. Keep lifecycle and data ownership explicit.

Related Guides