Testing Docker Networks and Volumes: Integration Test Patterns

Testing Docker Networks and Volumes: Integration Test Patterns

Docker networks and volumes are often taken for granted in test setups, but they're the source of subtle bugs that only appear in multi-container environments. This guide covers how to test network connectivity between containers, validate volume behavior, and catch data persistence issues before production.

Why Network and Volume Testing Matters

Common bugs caused by Docker networking/volume misconfiguration:

  • Service A can't reach Service B because they're on different Docker networks
  • Data written to a volume during tests persists into the next test run, causing false passes
  • Volume permissions cause EACCES errors only when running as non-root (production) but not as root (dev)
  • Named volumes survive container restarts but bind mounts don't — and your app depends on a file that disappears
  • Network aliases (service names) work in Docker Compose but not in raw docker run commands, causing CI failures

Testing Multi-Container Network Connectivity

// network.test.js
const { execSync, exec } = require('child_process');
const util = require('util');
const execAsync = util.promisify(exec);

describe('Docker network connectivity', () => {
  const networkName = `test-net-${Date.now()}`;
  
  beforeAll(async () => {
    // Create isolated test network
    execSync(`docker network create ${networkName}`);
    
    // Start backend API
    execSync(`
      docker run -d \
        --name backend-${networkName} \
        --network ${networkName} \
        --network-alias backend \
        myapp-backend:test
    `);
    
    // Wait for backend readiness
    await waitForContainer(`backend-${networkName}`, '/health');
  });
  
  afterAll(async () => {
    execSync(`docker stop backend-${networkName} || true`);
    execSync(`docker rm backend-${networkName} || true`);
    execSync(`docker network rm ${networkName}`);
  });
  
  test('frontend can reach backend by alias', async () => {
    // Run a container in the same network and try to reach backend by alias
    const { stdout } = await execAsync(`
      docker run --rm \
        --network ${networkName} \
        curlimages/curl:latest \
        curl -sf http://backend:8080/health
    `);
    
    const response = JSON.parse(stdout);
    expect(response.status).toBe('ok');
  });
  
  test('containers on different networks cannot communicate', async () => {
    // Run container on default network, try to reach our backend
    await expect(execAsync(`
      docker run --rm \
        curlimages/curl:latest \
        curl -sf --max-time 5 http://backend-${networkName}:8080/health
    `)).rejects.toThrow();
  });
  
  test('DNS resolution works for service aliases', async () => {
    const { stdout } = await execAsync(`
      docker run --rm \
        --network ${networkName} \
        busybox:latest \
        nslookup backend
    `);
    
    expect(stdout).toContain('Address:');
    // Should resolve to a 172.x.x.x address (Docker bridge network)
    expect(stdout).toMatch(/172\.\d+\.\d+\.\d+/);
  });
});

async function waitForContainer(containerName, healthPath, maxWait = 30000) {
  const start = Date.now();
  while (Date.now() - start < maxWait) {
    try {
      execSync(`docker exec ${containerName} wget -q --spider http://localhost${healthPath}`, {
        stdio: 'ignore'
      });
      return;
    } catch {
      await new Promise(resolve => setTimeout(resolve, 1000));
    }
  }
  throw new Error(`Container ${containerName} did not become healthy in ${maxWait}ms`);
}

Testing Volume Data Persistence

describe('Docker volume persistence', () => {
  const volumeName = `test-vol-${Date.now()}`;
  
  afterAll(() => {
    execSync(`docker volume rm ${volumeName} || true`);
  });
  
  test('data written to named volume persists across container restarts', async () => {
    // Write data in first container instance
    execSync(`
      docker run --rm \
        -v ${volumeName}:/data \
        busybox \
        sh -c 'echo "test-data-$(date +%s)" > /data/test.txt'
    `);
    
    // Read data in second container instance
    const { stdout } = await execAsync(`
      docker run --rm \
        -v ${volumeName}:/data \
        busybox \
        cat /data/test.txt
    `);
    
    expect(stdout.trim()).toMatch(/^test-data-\d+$/);
  });
  
  test('volume data is isolated between named volumes', async () => {
    const volumeA = `test-vol-a-${Date.now()}`;
    const volumeB = `test-vol-b-${Date.now()}`;
    
    try {
      execSync(`docker run --rm -v ${volumeA}:/data busybox sh -c 'echo "volume-a" > /data/id.txt'`);
      execSync(`docker run --rm -v ${volumeB}:/data busybox sh -c 'echo "volume-b" > /data/id.txt'`);
      
      const contentA = execSync(`docker run --rm -v ${volumeA}:/data busybox cat /data/id.txt`).toString().trim();
      const contentB = execSync(`docker run --rm -v ${volumeB}:/data busybox cat /data/id.txt`).toString().trim();
      
      expect(contentA).toBe('volume-a');
      expect(contentB).toBe('volume-b');
    } finally {
      execSync(`docker volume rm ${volumeA} ${volumeB} || true`);
    }
  });
  
  test('tmpfs volume does not persist', async () => {
    // Start container with tmpfs mount
    execSync(`
      docker run -d --name tmpfs-test \
        --tmpfs /tmp \
        busybox \
        sh -c 'echo "ephemeral" > /tmp/data.txt && sleep 60'
    `);
    
    // Verify data is there
    let content = execSync('docker exec tmpfs-test cat /tmp/data.txt').toString().trim();
    expect(content).toBe('ephemeral');
    
    // Restart container
    execSync('docker restart tmpfs-test');
    
    // Data should be gone after restart
    const result = execSync('docker exec tmpfs-test ls /tmp/data.txt 2>&1 || echo "NOT_FOUND"').toString().trim();
    expect(result).toBe('NOT_FOUND');
    
    execSync('docker stop tmpfs-test && docker rm tmpfs-test');
  });
});

Testing Volume Permissions

Permission issues on volumes often only appear in production (non-root user) but not in development (root):

describe('Docker volume permissions', () => {
  const volumeName = `perm-test-${Date.now()}`;
  
  afterAll(() => execSync(`docker volume rm ${volumeName} || true`));
  
  test('non-root user can write to volume when ownership is correct', async () => {
    // Create volume with correct ownership for non-root user (UID 1000)
    execSync(`
      docker run --rm \
        -v ${volumeName}:/data \
        busybox \
        sh -c 'chown 1000:1000 /data'
    `);
    
    // Non-root user should be able to write
    const { stdout } = await execAsync(`
      docker run --rm \
        --user 1000:1000 \
        -v ${volumeName}:/data \
        busybox \
        sh -c 'echo "written-by-nonroot" > /data/test.txt && cat /data/test.txt'
    `);
    
    expect(stdout.trim()).toBe('written-by-nonroot');
  });
  
  test('non-root user cannot write to root-owned volume', async () => {
    const rootVol = `root-vol-${Date.now()}`;
    try {
      // Volume owned by root
      execSync(`docker run --rm -v ${rootVol}:/data busybox sh -c 'chmod 700 /data'`);
      
      // Non-root should fail to write
      await expect(execAsync(`
        docker run --rm --user 1000:1000 -v ${rootVol}:/data \
          busybox sh -c 'echo "fail" > /data/test.txt'
      `)).rejects.toThrow();
    } finally {
      execSync(`docker volume rm ${rootVol} || true`);
    }
  });
  
  test('bind mount inherits host file permissions', async () => {
    const tmpDir = execSync('mktemp -d').toString().trim();
    
    // Create file with specific permissions on host
    execSync(`touch ${tmpDir}/readonly.txt && chmod 444 ${tmpDir}/readonly.txt`);
    
    // Container sees same permissions
    const perms = execSync(`
      docker run --rm \
        -v ${tmpDir}:/mnt \
        busybox \
        stat -c '%a' /mnt/readonly.txt
    `).toString().trim();
    
    expect(perms).toBe('444');
    
    execSync(`rm -rf ${tmpDir}`);
  });
});

Integration Testing with Database Volumes

Test that your app handles database container restarts correctly:

describe('Database persistence across container restarts', () => {
  const volumeName = `db-vol-${Date.now()}`;
  const networkName = `db-net-${Date.now()}`;
  
  beforeAll(async () => {
    execSync(`docker network create ${networkName}`);
    execSync(`
      docker run -d \
        --name postgres-persistent \
        --network ${networkName} \
        --network-alias postgres \
        -v ${volumeName}:/var/lib/postgresql/data \
        -e POSTGRES_PASSWORD=test \
        -e POSTGRES_DB=testdb \
        postgres:15
    `);
    
    // Wait for Postgres
    await waitForPostgres('postgres-persistent');
  });
  
  afterAll(() => {
    execSync('docker stop postgres-persistent && docker rm postgres-persistent || true');
    execSync(`docker network rm ${networkName} || true`);
    execSync(`docker volume rm ${volumeName} || true`);
  });
  
  test('data survives container restart', async () => {
    // Insert data
    execSync(`
      docker exec postgres-persistent psql -U postgres testdb \
        -c "CREATE TABLE IF NOT EXISTS test_items (id serial, value text);"
    `);
    execSync(`
      docker exec postgres-persistent psql -U postgres testdb \
        -c "INSERT INTO test_items (value) VALUES ('persistent-data');"
    `);
    
    // Restart container
    execSync('docker restart postgres-persistent');
    await waitForPostgres('postgres-persistent');
    
    // Verify data survives
    const result = execSync(`
      docker exec postgres-persistent psql -U postgres testdb \
        -t -c "SELECT value FROM test_items;"
    `).toString().trim();
    
    expect(result).toContain('persistent-data');
  });
  
  test('app reconnects after database restart', async () => {
    // Start app container
    execSync(`
      docker run -d \
        --name app-reconnect-test \
        --network ${networkName} \
        -e DATABASE_URL=postgresql://postgres:test@postgres/testdb \
        -p 3001:3000 \
        myapp:test
    `);
    
    await waitForContainer('app-reconnect-test', '/health');
    
    // Verify app works
    let response = await fetch('http://localhost:3001/health');
    expect(response.ok).toBe(true);
    
    // Restart database
    execSync('docker restart postgres-persistent');
    
    // Wait for postgres to be back
    await waitForPostgres('postgres-persistent');
    
    // App should reconnect and respond correctly
    // Retry up to 30 seconds
    let reconnected = false;
    for (let i = 0; i < 30; i++) {
      try {
        response = await fetch('http://localhost:3001/health');
        if (response.ok) {
          reconnected = true;
          break;
        }
      } catch {}
      await new Promise(resolve => setTimeout(resolve, 1000));
    }
    
    expect(reconnected).toBe(true);
    execSync('docker stop app-reconnect-test && docker rm app-reconnect-test');
  });
});

async function waitForPostgres(containerName, maxWait = 30000) {
  const start = Date.now();
  while (Date.now() - start < maxWait) {
    try {
      execSync(`docker exec ${containerName} pg_isready -U postgres`, { stdio: 'ignore' });
      return;
    } catch {
      await new Promise(resolve => setTimeout(resolve, 1000));
    }
  }
  throw new Error(`Postgres in ${containerName} did not become ready in ${maxWait}ms`);
}

Network Mode Testing

Test how your app behaves across different Docker network modes:

# Test with bridge network (default)
docker run --rm --network bridge myapp:test curl http://httpbin.org/get

# Test with host network (shares host network namespace)
docker run --rm --network host myapp:test curl http://localhost:8080/health

# Test with no network (maximum isolation)
docker run --rm --network none myapp:test node -e "
  const http = require('http');
  http.get('http://example.com', () => {}).on('error', (e) => {
    process.exit(e.code === 'ENOTFOUND' ? 0 : 1);
  });
"

For security testing — verifying your app doesn't make unexpected outbound connections:

# Run with network=none, only internal connections allowed
docker run --rm \
  --network none \
  -e DATABASE_URL=sqlite:///tmp/test.db \
  myapp:test \
  npm test

If your tests pass without external network access, you've verified they don't have hidden external dependencies.

Cleaning Up Between Tests

Volume and network cleanup is critical for test isolation:

// docker-cleanup.js — use in test hooks
const { execSync } = require('child_process');

function cleanupDockerResources(prefix) {
  // Stop and remove containers with prefix
  const containers = execSync(
    `docker ps -aq --filter "name=${prefix}" 2>/dev/null || echo ""`
  ).toString().trim();
  
  if (containers) {
    execSync(`docker stop ${containers} 2>/dev/null || true`);
    execSync(`docker rm ${containers} 2>/dev/null || true`);
  }
  
  // Remove networks
  const networks = execSync(
    `docker network ls -q --filter "name=${prefix}" 2>/dev/null || echo ""`
  ).toString().trim();
  
  if (networks) {
    execSync(`docker network rm ${networks} 2>/dev/null || true`);
  }
  
  // Remove volumes
  const volumes = execSync(
    `docker volume ls -q --filter "name=${prefix}" 2>/dev/null || echo ""`
  ).toString().trim();
  
  if (volumes) {
    execSync(`docker volume rm ${volumes} 2>/dev/null || true`);
  }
}

// Use timestamp prefix to avoid conflicts between parallel test runs
const testId = `test-${Date.now()}`;

afterAll(() => cleanupDockerResources(testId));

Network and volume testing is often skipped because "it works on my machine" — but that's precisely when these bugs hide. A bind mount works on a developer's Mac but fails on a Linux CI runner due to permissions. A named volume from a failed test run corrupts the next test's database state. Testing these explicitly catches the class of bugs that are hardest to reproduce.

Read more

Start now free