Repository navigation
Examples
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.
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!');
});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);
});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');
});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!');
});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' });
});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!');
});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!');
});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');
});If you're developing a GitHub Action, write the test inside the action's own repository, with a workflow that:
- Runs
actions/checkout@vXfirst.actintercepts this action and copies your local files into the 's workspace instead of performing a real network clone. - References the action with
uses: ./(i.e., the path containing youraction.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!');
});