Skip to content

Examples

Pavlo Shevchenko edited this page Sep 14, 2026 · 8 revisions

Representative test cases showing how to use act-test-runner. See the README for a quick taste, and the Known limitations page for constraints to keep in mind.

Basic workflow execution

test('custom workflow', async () => {
  const result = await new ActRunner()
    .withWorkflow({
      body: `
name: Simple passing workflow
on: [push]

jobs:
  successful_job:
    runs-on: ubuntu-latest
    steps:
      - name: Successful step
        run: echo "Hello, World!"
  `,
    })
    .run();

  expect(result.status).toBe(ActExecStatus.SUCCESS);
  expect(result.output).toContain('Hello, World!');
  expect(Object.keys(result.jobs).length).toBe(1);

  const successfulJob = result.jobs['successful_job']!;
  expect(successfulJob.status).toBe(ActExecStatus.SUCCESS);
  expect(successfulJob.output).toContain('Hello, World!');
});

Inspecting job and step results

Each job result exposes its own steps, so you can assert on individual step outcomes, not just the job as a whole.

test('workflow with multiple steps', async () => {
  const result = await new ActRunner()
    .withWorkflow({
      body: `
name: Two-step workflow
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Install
        run: echo "Installing dependencies"
      - name: Test
        run: exit 1
  `,
    })
    .run();

  expect(result.status).toBe(ActExecStatus.FAILED);

  const [install, test] = result.jobs['build']!.steps;
  expect(install!.status).toBe(ActExecStatus.SUCCESS);
  expect(test!.status).toBe(ActExecStatus.FAILED);
});

Selecting which event triggers a workflow

withEvent lets you pick which of a workflow's triggers to run with, and optionally pass a payload for that event (as a plain object, shown below, or a path to a JSON file), so you can test each event handling path in isolation.

test('workflow triggered by a specific event', async () => {
  const result = await new ActRunner()
    .withWorkflow({
      body: `
name: Workflow reacting to issues and pull requests
on: [issues, pull_request]

jobs:
  on_issue:
    runs-on: ubuntu-latest
    if: github.event_name == 'issues'
    steps:
      - run: echo "Handling an issue"
  on_pull_request:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - run: echo "PR title: ${{ github.event.pull_request.title }}"
  `,
    })
    .withEvent('pull_request', { pull_request: { title: 'Fix flaky test' } })
    .run();

  expect(result.jobs['on_issue']!.status).toBe(ActExecStatus.SKIPPED);

  const prJob = result.jobs['on_pull_request']!;
  expect(prJob.status).toBe(ActExecStatus.SUCCESS);
  expect(prJob.output).toContain('PR title: Fix flaky test');
});

Passing values to a workflow

withInputs, withSecrets, and withVariables accept the same { values, file } shape to configure workflow inputs, secrets, and repository/environment variables, respectively. Values can be loaded from a file, set inline, or both combined, with inline values taking precedence.

test('passing environment variables to a workflow', async () => {
  const result = await new ActRunner()
    .withWorkflow({
      body: `
name: Workflow reading environment variables
on: [push]

jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - run: echo "$GREETING, $NAME!"
  `,
    })
    .withEnv({ values: { GREETING: 'Hello', NAME: 'Bruce' } })
    .run();

  expect(result.status).toBe(ActExecStatus.SUCCESS);
  expect(result.jobs['greet']!.output).toContain('Hello, Bruce!');
});

Restricting a job matrix

By default, a matrix job runs with every combination defined in the workflow. withMatrix restricts it to a single combination.

test('restricting which matrix combination runs', async () => {
  const result = await new ActRunner()
    .withWorkflow({
      body: `
name: Matrix workflow
on: [push]

jobs:
  greet:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        greeting: [Hello, Hallo]
        name: [Bruce, Falco]
    steps:
      - run: echo "${{ matrix.greeting }}, ${{ matrix.name }}!"
  `,
    })
    .withMatrix({ greeting: 'Hallo', name: 'Bruce' })
    .run();

  const job = Object.values(result.jobs)[0]!;
  expect(job.status).toBe(ActExecStatus.SUCCESS);
  expect(job.output).toContain('Hallo, Bruce!');
  expect(job.matrix).toStrictEqual({ greeting: 'Hallo', name: 'Bruce' });
});

Capturing output with a custom listener

By default, workflow output is only available on the result object once the run completes. A custom ActOutputListener receives output as it's produced, which is useful for streaming logs or asserting on specific messages.

class CollectingListener implements ActOutputListener {
  messages: string[] = [];

  onOutput(output: ActOutput): void {
    this.messages.push(output.message.trim());
  }
}

test('forwarding output to a custom listener', async () => {
  const listener = new CollectingListener();

  const result = await new ActRunner()
    .withWorkflow({
      body: `
name: Simple passing workflow
on: [push]

jobs:
  successful_job:
    runs-on: ubuntu-latest
    steps:
      - name: Successful step
        run: echo "Hello, World!"
  `,
    })
    .forwardOutput(listener)
    .run();

  expect(result.status).toBe(ActExecStatus.SUCCESS);
  expect(listener.messages).toContain('Hello, World!');
});

Persisting cache entries in a custom directory

withCacheServer points actions/cache at a local directory, so cache entries survive across separate runs instead of disappearing with the container. The following example demonstrates how the second workflow run reuses the cached content instead of recreating the file.

withArtifactServer works the same way for actions/upload-artifact and actions/download-artifact. Note that those actions require an ACTIONS_RUNTIME_TOKEN environment variable to be set, for example via withEnv({ values: { ACTIONS_RUNTIME_TOKEN: 'irrelevant' } }).

test('cache entry survives across runs', async () => {
  const cacheDir = '/tmp/act-cache';
  const workflow = {
    body: `
name: Workflow storing a file in cache
on: [push]

jobs:
  cache_file:
    runs-on: ubuntu-latest
    steps:
      - name: Restore from cache
        id: cache
        uses: actions/cache@v4
        with:
          path: greeting.txt
          key: greeting-cache
      - name: Create greeting file
        if: steps.cache.outputs.cache-hit != 'true'
        run: echo "Hello, World!" > greeting.txt
      - name: Print greeting file
        run: cat greeting.txt
  `,
  };

  // first run: no cache entry yet, so the file is created and cached
  await new ActRunner().withWorkflow(workflow).withCacheServer({ path: cacheDir }).run();

  // second run: the file is restored from cache instead of being recreated
  const result = await new ActRunner().withWorkflow(workflow).withCacheServer({ path: cacheDir }).run();

  expect(result.status).toBe(ActExecStatus.SUCCESS);
  const job = result.jobs['cache_file']!;
  expect(job.steps.map((step) => step.name)).not.toContain('Create greeting file');
  expect(job.output).toContain('Hello, World!');
});

Mocking the GitHub API with msw

const handlers = [
  http.get('*/users/:username', ({ params }) => HttpResponse.json({ login: params['username'], id: 12_345_678 })),
];

// setupServer only intercepts requests made from the test process, while workflow steps run inside a container
const mockGitHubApi = createServer(async (req, res) => {
  const response = await getResponse(handlers, new Request(`http://localhost${req.url}`, { method: req.method }));

  res.writeHead(response?.status ?? 404, Object.fromEntries(response?.headers ?? []));
  res.end(await response?.text());
});

beforeAll(async () => {
  mockGitHubApi.listen(9999);
  await once(mockGitHubApi, 'listening');
});

afterAll(async () => {
  mockGitHubApi.close();
  await once(mockGitHubApi, 'close');
});

test('workflow reads from the mocked GitHub API', async () => {
  const result = await new ActRunner()
    .withWorkflow({
      body: `
name: Workflow calling the GitHub API
on: [push]

jobs:
  print_user_id:
    runs-on: ubuntu-latest
    steps:
      - run: |
          node -e "
            fetch(process.env.GITHUB_API_URL + '/users/octocat')
              .then((response) => response.json())
              .then((user) => console.log('User ID: ' + user.id));
          "
  `,
    })
    // `actions/github-script`, Octokit, the `gh` CLI, a plain `fetch` respect this variable and will talk to the mock
    .withEnv({ values: { GITHUB_API_URL: 'http://host.docker.internal:9999' } })
    // lets the runner container reach the localhost
    .withAdditionalArgs('--container-options', '--add-host=host.docker.internal:host-gateway')
    .run();

  expect(result.jobs['print_user_id']!.output).toContain('User ID: 12345678');
});

Testing a local action under development

If you're developing a GitHub Action, write the test inside the action's own repository, with a workflow that:

  1. Runs actions/checkout@vX first. act intercepts this action and copies your local files into the 's workspace instead of performing a real network clone.
  2. References the action with uses: ./ (i.e., the path containing your action.yml). The path must be relative to the Node invocation directory, which is usually the action repository's root.
test('local action prints a greeting', async () => {
  const result = await new ActRunner()
    .withWorkflow({
      body: `
name: Test local action
on: [push]

jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: ./
        with:
          name: Bruce
  `,
    })
    .run();

  expect(result.status).toBe(ActExecStatus.SUCCESS);
  expect(result.jobs['greet']!.output).toContain('Hello, Bruce!');
});