Dagger TypeScript SDK: CI Testing Pipelines for Node.js and Next.js

Dagger TypeScript SDK: CI Testing Pipelines for Node.js and Next.js

The Dagger TypeScript SDK lets you write CI pipelines in TypeScript — the same language as your application. Your pipeline is type-safe, unit-testable, and runs identically on your laptop and in GitHub Actions.

This guide focuses on Node.js and Next.js testing workflows: running Vitest, Jest, Playwright, and integrating with services like PostgreSQL and Redis.

Setup

npm install -g @dagger.io/dagger
dagger init --sdk typescript --name my-pipeline

This creates:

myproject/
  dagger/
    src/
      index.ts       # Your pipeline
    package.json
  dagger.json

Basic Test Pipeline

// dagger/src/index.ts
import { dag, Container, Directory, object, func } from "@dagger.io/dagger"

@object()
class MyPipeline {
  
  @func()
  async test(source: Directory): Promise<string> {
    return dag
      .container()
      .from("node:22-alpine")
      .withDirectory("/app", source, {
        exclude: ["node_modules", ".next", "dist", ".turbo"],
      })
      .withWorkdir("/app")
      .withExec(["npm", "ci"])
      .withExec(["npm", "test", "--", "--run"])  // --run for Vitest one-shot
      .stdout()
  }
}

Run it:

dagger call test --<span class="hljs-built_in">source .

Caching node_modules

The biggest CI speedup: cache node_modules across runs.

@func()
async test(source: Directory): Promise<string> {
  const nodeCache = dag.cacheVolume("npm-cache")
  const nmCache = dag.cacheVolume("node-modules")
  
  return dag
    .container()
    .from("node:22-alpine")
    .withMountedCache("/root/.npm", nodeCache)
    .withDirectory("/app", source, {
      exclude: ["node_modules", ".next"],
    })
    .withWorkdir("/app")
    .withMountedCache("/app/node_modules", nmCache)
    .withExec(["npm", "ci"])
    .withExec(["npm", "test", "--", "--run"])
    .stdout()
}

With Dagger Cloud, nmCache is shared across all your CI runners. npm ci becomes a no-op if package-lock.json hasn't changed.

Next.js Testing Pipeline

@object()
class NextPipeline {
  
  /**
   * Run Next.js unit tests with Jest/Vitest.
   */
  @func()
  async unitTest(source: Directory): Promise<string> {
    return dag
      .container()
      .from("node:22-alpine")
      .withDirectory("/app", source, {
        exclude: ["node_modules", ".next"],
      })
      .withWorkdir("/app")
      .withExec(["npm", "ci"])
      .withExec(["npm", "run", "test:unit"])
      .stdout()
  }
  
  /**
   * Build Next.js app and run Playwright E2E tests.
   */
  @func()
  async e2eTest(source: Directory): Promise<string> {
    // Build the app as a container
    const appContainer = dag
      .container()
      .from("node:22-alpine")
      .withDirectory("/app", source, {
        exclude: ["node_modules", ".next"],
      })
      .withWorkdir("/app")
      .withExec(["npm", "ci"])
      .withExec(["npm", "run", "build"])
      .withExec(["npm", "start"])
      .withExposedPort(3000)
    
    const app = appContainer.asService()
    
    // Run Playwright against the running app
    return dag
      .container()
      .from("mcr.microsoft.com/playwright:v1.50.0-noble")
      .withServiceBinding("app", app)
      .withDirectory("/tests", source.directory("e2e"))
      .withWorkdir("/tests")
      .withExec(["npm", "ci"])
      .withEnvVariable("BASE_URL", "http://app:3000")
      .withExec(["npx", "playwright", "test", "--reporter=line"])
      .stdout()
  }
  
  /**
   * TypeScript type checking.
   */
  @func()
  async typecheck(source: Directory): Promise<string> {
    return dag
      .container()
      .from("node:22-alpine")
      .withDirectory("/app", source)
      .withWorkdir("/app")
      .withExec(["npm", "ci"])
      .withExec(["npx", "tsc", "--noEmit"])
      .stdout()
  }
  
  /**
   * Full CI: lint + typecheck + unit (parallel), then E2E.
   */
  @func()
  async ci(source: Directory): Promise<string> {
    const [lintResult, typeResult, unitResult] = await Promise.all([
      this.lint(source),
      this.typecheck(source),
      this.unitTest(source),
    ])
    
    // E2E runs after unit tests pass
    await this.e2eTest(source)
    
    return `CI passed\nLint: OK\nTypes: OK\nUnit: OK\nE2E: OK`
  }
  
  private async lint(source: Directory): Promise<string> {
    return dag
      .container()
      .from("node:22-alpine")
      .withDirectory("/app", source)
      .withWorkdir("/app")
      .withExec(["npm", "ci"])
      .withExec(["npm", "run", "lint"])
      .stdout()
  }
}

Integration Tests with PostgreSQL

@func()
async integrationTest(
  source: Directory,
  dbPassword: Secret,
): Promise<string> {
  // Start PostgreSQL
  const postgres = dag
    .container()
    .from("postgres:16-alpine")
    .withEnvVariable("POSTGRES_USER", "testuser")
    .withSecretVariable("POSTGRES_PASSWORD", dbPassword)
    .withEnvVariable("POSTGRES_DB", "testdb")
    .withExposedPort(5432)
    .asService()
  
  return dag
    .container()
    .from("node:22-alpine")
    .withServiceBinding("postgres", postgres)
    .withSecretVariable("DB_PASSWORD", dbPassword)
    .withEnvVariable(
      "DATABASE_URL",
      "postgresql://testuser@postgres:5432/testdb"
    )
    .withDirectory("/app", source)
    .withWorkdir("/app")
    .withExec(["npm", "ci"])
    .withExec(["npx", "prisma", "migrate", "deploy"])
    .withExec(["npm", "run", "test:integration"])
    .stdout()
}

Vitest vs Jest in Dagger

Both work identically in Dagger containers. The command changes, nothing else:

Vitest:

.withExec(["npx", "vitest", "run", "--reporter=verbose"])

Jest:

.withExec(["npx", "jest", "--ci", "--passWithNoTests"])

Jest with coverage:

.withExec([
  "npx", "jest",
  "--ci",
  "--coverage",
  "--coverageDirectory=/tmp/coverage",
  "--coverageReporters=text,lcov",
])

Exporting Test Artifacts

Return coverage reports or test results as files:

@func()
async testWithCoverage(source: Directory): Promise<Directory> {
  const container = dag
    .container()
    .from("node:22-alpine")
    .withDirectory("/app", source)
    .withWorkdir("/app")
    .withExec(["npm", "ci"])
    .withExec([
      "npx", "vitest", "run",
      "--coverage",
      "--coverage.reporter=html",
      "--coverage.reportsDirectory=/app/coverage",
    ])
  
  // Return the coverage directory
  return container.directory("/app/coverage")
}
# Export coverage to local ./coverage/ directory
dagger call test-with-coverage --<span class="hljs-built_in">source . <span class="hljs-built_in">export --path ./coverage

Monorepo Testing with Turbo/Nx

For Turborepo monorepos:

@func()
async turboTest(
  source: Directory,
  filter: string = "...", // All packages by default
): Promise<string> {
  const turboCache = dag.cacheVolume("turbo-cache")
  
  return dag
    .container()
    .from("node:22-alpine")
    .withMountedCache("/root/.cache/turbo", turboCache)
    .withDirectory("/app", source, {
      exclude: ["**/node_modules", "**/.next", "**/.turbo"],
    })
    .withWorkdir("/app")
    .withExec(["npm", "ci"])
    .withExec([
      "npx", "turbo", "run", "test",
      `--filter=${filter}`,
      "--output-logs=new-only",
    ])
    .stdout()
}

The turboCache volume preserves Turbo's build cache between CI runs, so unchanged packages skip testing entirely.

GitHub Actions Integration

name: CI
on: [push, pull_request]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Dagger
        uses: dagger/dagger-for-github@v7
        with:
          version: "latest"
      
      - name: Run CI
        run: dagger call ci --source .
        env:
          DAGGER_CLOUD_TOKEN: ${{ secrets.DAGGER_CLOUD_TOKEN }}
          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

Debugging Locally

When a test fails in CI, reproduce it immediately:

# Run exactly what CI runs
dagger call ci --<span class="hljs-built_in">source .

<span class="hljs-comment"># Run just unit tests
dagger call unit-test --<span class="hljs-built_in">source .

<span class="hljs-comment"># Shell into the test container
dagger call unit-test --<span class="hljs-built_in">source . --interactive

The --interactive flag drops you into a shell inside the container at the point of failure. Inspect the filesystem, run individual tests, check environment variables.

Adding HelpMeTest for Full Browser Testing

For full E2E browser testing beyond Playwright scripts, HelpMeTest runs Robot Framework + Playwright in the cloud:

@func()
async browserTest(
  source: Directory,
  token: Secret,
  appUrl: string,
): Promise<string> {
  return dag
    .container()
    .from("node:22-alpine")
    .withExec(["npm", "install", "-g", "helpmetest"])
    .withSecretVariable("HELPMETEST_TOKEN", token)
    .withExec([
      "helpmetest", "run",
      "--project", "my-app",
      "--base-url", appUrl,
      "--wait",
    ])
    .stdout()
}

HelpMeTest's free tier includes 10 tests with 24/7 monitoring — useful for catching regressions between CI runs.

Summary

The Dagger TypeScript SDK brings type-safe, locally executable CI pipelines to Node.js teams:

  • Type safety: pipeline errors caught by the TypeScript compiler
  • Local execution: dagger call test --source . works identically in CI
  • Parallelism: Promise.all() runs steps concurrently
  • Caching: cacheVolume() makes npm ci fast after the first run
  • Services: PostgreSQL, Redis, and any Docker image as a sidecar

Start by converting your npm test step to a Dagger function. Once you've debugged one CI failure locally in seconds instead of pushing and waiting 5 minutes, you'll convert the rest.

Read more