
In this article, we’ll walk you through how to set up an automated unit test suite for your Auth0 Actions using Kilterset’s Auth0 Actions Testing library.
Why test your actions?
Auth0 Actions are often simple but critical pieces of code. A small bug in an Action can lead to a major business disruption, with customers and staff alike left unable access services.
So like any critical code, we’re writing automated tests around it, right?
Sadly, the answer is often “No”. Auth0 developers get by with the manual “Test” button and rarely do thorough checks of all the branching and error logic. And if they do make the effort, it’s unlikely they do it every time they make a change.
A well-written automated test suite helps keep code readable, reliable, and maintainable. In contrast to one-off testing, it can be run instantaneously after each proposed change to validate behaviour. It finds bugs that might otherwise not be detected, helps developers refactor with confidence, and deepens understanding for a new developer looking at the code for the first time.
Auth0 itself suggests an approach to unit testing, but the implementation is left as an exercise to the reader. The problem is, while writing an Action appears to be a simple task, doing the work of setting up a true test suite has not been trivial.
However, as our team saw different Auth0 customers working with Actions and as we developed more complex Actions for the Auth0 marketplace, we became motivated to solve this problem. We wanted to create an easy way to add a test suite to your Actions and bring the mature practices of your software development lifecycle – version control, test-driven development, red-green-refactor programming, peer review and CI/CD pipeline – to Actions. The result was the open source Auth0 Actions Testing library. We’ve done the hard part of mocking each Flow and Trigger in Auth0, providing realistic, random event data and packaging it into a simple library for you to consume.
Tutorial
We’re going to focus on testing a problem you might solve with an Action. Here’s the scenario:
Marketing has an API you can check to see if the user is eligible for a one-time special offer. If they are eligible, Marketing wants you to redirect the user the next time they sign in, but only once. If they are not eligible, they continue to sign in as usual.
Create the Action
In a pre-production Auth0 tenant, create a new Action by visiting Actions > Library > Create Action > Build From Scratch. Call it “One-Time Marketing Offer” and with the “Login/Post Login” trigger and the latest runtime (Node 18 or higher). You can close the Auth0 Dashboard. From here on, we’ll be making changes locally, in version control.
Export the Action with a0deploy
We recommend using Auth0’s a0deploy deployment CLI to manage your Auth0 configuration in version control. If you’re not already doing this, now’s a good time to start (see instructions).
If you’re new to a0deploy, it can be overwhelming. One trick to make it more manageable is to set AUTH0_INCLUDED_ONLY to limit the scope of what’s managed in version control. You can use this setting to focus only on Actions in version control to begin with.
Here’s what a sample config.json looks like:
{
"AUTH0_DOMAIN": "your-domain.us.auth0.com",
"AUTH0_CLIENT_ID": "...",
"AUTH0_CLIENT_SECRET": "...",
"AUTH0_INCLUDED_ONLY": ["actions"]
}
Running a0deploy --config_file config.json --format yaml --output_folder . should produce a structure that should include this:
.
├── actions
│ └── One-Time Marketing Offer
│ └── code.js
└── config.json
You can check this into version control (e.g. git init).
Install Node.js locally
To test and run this code on your local machine, you’ll need an appropriate version of Node.js installed: the one you chose when you created the Action. At the time of writing, the latest version supported is Node 18, which is several years old and may be difficult to install. We recommend using a Node version manager to help manage and switch between different versions of Node.
You can confirm you have the right version of Node available with the command node --version. For example:
$ node --version
v18.19.0
Set up the testing library
Now we have our code checked out, we need to add the testing library. The convention we use is to set up a project inside the actions directory using Node’s built-in package manager, NPM. You can do this with npm init and the engines property set, or you copy the one below (as actions/package.json):
{
"name": "actions-tests",
"version": "1.0.0",
"engines": {
"node": "^18.19.0"
}
}
The engines property makes sure the code always runs with the same version of Node in use on Auth0 (e.g. Node 18 in this case). The directory structure should now look like this:
.
├── actions
│ └── One-Time Marketing Offer
│ │ └── code.js
│ └── package.json
└── config.json
Now we can add the testing library:
cd actions # or wherever your package.json is
npm install @kilterset/auth0-actions-testing --save-dev
You can verify that it has been installed with npm list (look for @kilterset/auth0-actions-testing).
NPM creates a node_modules directory that you don’t want to store in version control, so now is a good time to ignore this directory from your version control. For example, in .gitignore:
# Exclude local Node packages from Git
node_modules/
Writing your first test
To write tests, we use Node’s built-in test runner. While not as popular as third-party alternatives such as Jest, it’s available out-of-the-box with Node and requires no extra dependencies. (If you’d like us to consider Jest support in a future release, please let us know via GitHub.)
Let’s write a basic test, by creating test.js alongside our action’s code.js:
.
└── actions
└── One-Time Marketing Offer
├── code.js
└── test.js
Here’s the test:
const test = require("node:test");
const { ok, strictEqual, deepStrictEqual } = require("node:assert");
const { nodeTestRunner } = require("@kilterset/auth0-actions-testing");
const { onExecutePostLogin } = require("./code");
test("One-Time Marketing Offer", async (t) => {
const { auth0, fetchMock } = await nodeTestRunner.actionTestSetup(t);
await t.test("runs without errors", async () => {
const action = auth0.mock.actions.postLogin();
await action.simulate(onExecutePostLogin);
});
});
Let’s break this down:
- Eagle-eyed Node developers will notice we are using the CommonJS
requiresyntax here instead of ESM’simport. We do this to be consistent with Auth0’s use of CommonJS in the Actions themselves. - On line 2, we import some assertions to verify behaviour. We’ll use these shortly.
- We
requiretheonExecutePostLoginfrom our Action stored incode.js. Note: different Actions will have different exports based on their Flow. - The outer
test()call encapsulates the suite of tests we want to write for this action. We describe individual test scenarios withawait t.test(). - We call
await nodeTestRunner.actionTestSetup()to set up support for testing Auth0 actions for the Node test runner. We only need to do this once per test suite, inside the outertest(). auth0.mock.actions.postLogin()prepares a realistic but random mock payload for the Action. You can further customize this mock payload to include information about the user, secrets, request, etc.await action.simulate()allows us to simulate our Action.
Running the test
You can run this with node --test. The output should look like:
▶ One-Time Marketing Offer
✔ runs without errors (11.84275ms)
▶ One-Time Marketing Offer (48.508583ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 147.809166
Redirecting the user
Our first test passes, but our code isn’t doing much. Let’s start by changing our “runs without errors” test to something more useful, like testing the redirect:
await t.test("redirects to one-time offer", async () => {
const action = auth0.mock.actions.postLogin();
await action.simulate(onExecutePostLogin);
ok(action.redirect, "Expected to be redirected");
strictEqual(
action.redirect.url.toString(),
"https://marketing.party/offer",
"Expected to be redirected to the offer"
);
});
n this test, we simulate the action and make two assertions:
- We use
ok(truthy [, failureMessage])to see if a redirect was set after running this action. - We use
strictEqual(actual, expected[, failureMesssage])to check that the redirect was set to the right location.
(We’ll focus on these two for now, but Node supports plenty of other assertions including match for regular expressions, deepStrictEqual for object comparison, rejects for promises, and throws for errors.)
Running node --test should result in a failure message.
✖ runs without errors (14.555167ms)
AssertionError [ERR_ASSERTION]: Expected to be redirected
at ...snip.. {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: null,
expected: true,
operator: '=='
}
Note the actual and expected in our failure. Let’s fix this in our code.js:
exports.onExecutePostLogin = async (event, api) => {
api.redirect.sendUserTo("https://marketing.party/offer")
};
Now the test should pass. Users will be redirected to https://marketing.party/offer?state=ABCXYZ. The state parameter is a special parameter Auth0 automatically adds to all redirects. The marketing website uses it to create “Continue sign in” links to https://{yourAuth0Domain}/continue?state=ABCXYZ, which will let the user resume their Login experience as normal.
Testing Auth0 user metadata
Marketing wants us to show the one-time offer only once. We can use app metadata to store whether or not the user has seen the one-time offer.
When working with redirects in Auth0, your code.js exposes a second function, called onContinuePostLogin. This is called once the user returns from a redirect. We can use this point of integration to track that they have seen the offer.
exports.onExecutePostLogin = async (event, api) => {
api.redirect.sendUserTo("https://marketing.party/offer")
};
// Called when the user is redirected back to the application after
// visiting the one-time offer.
exports.onContinuePostLogin = async (event, api) => {
api.user.setAppMetadata("visited_one_time_offer", true);
}
In our test, we need to update our require("code") to include onContinuePostLogin. We can then test it:
// ...snip...
const { onExecutePostLogin, onContinuePostLogin } = require("./code");
test("One-Time Marketing Offer", async (t) => {
// ...snip...
await t.test("redirects to one-time offer", async () => {
//...snip ...
});
await t.test("marks user as having visited the offer", async () => {
const action = auth0.mock.actions.postLogin();
await action.simulate(onContinuePostLogin);
strictEqual(
action.user.app_metadata.visited_one_time_offer,
true,
"Expected user to be marked as having visited the offer"
);
});
});
Testing conditional branches
We shouldn’t send users to the offer if they have already seen it. Let’s test this scenario:
await t.test("skips the offer if already visited", async () => {
const action = auth0.mock.actions.postLogin({
user: auth0.mock.user({
app_metadata: {
visited_one_time_offer: true,
},
}),
});
await action.simulate(onExecutePostLogin);
ok(!action.redirect, "Expected user to not be redirected");
});
Your Action receives a full event every time, filled with random but realistic data. (Try calling console.log(event) inside your Action.) The object you pass to auth0.mock.actions.postLogin() allows you to override properties in the event that your test cares about, such as the user, secrets, or request. We call these your test preconditions. In this case, our test precondition is to have a user whose app_metadata has a visited_one_time_offer property set to true.
By supplying and randomizing all the other supposedly-irrelevant event data, we’re increasing the chances a test run will surface a bug where your test was expecting a precondition that you hadn’t explicitly defined.
To make this test pass, we can add an if statement with an early return:
exports.onExecutePostLogin = async (event, api) => {
if (event.user.app_metadata.visited_one_time_offer) {
return;
}
api.redirect.sendUserTo("https://marketing.party/offer")
};
// ...snip...
Retrieving data from an API
Next we need to check to see whether the user is eligible for the deal. We can do this by querying an external marketing service with the user’s email address. This service requires a secret authorization token to use and returns a JSON response indicating the user’s eligibility. Writing this test will require mocking the HTTPS request.
You may have noticed in our outer test() we have this line:
const { auth0, fetchMock } = await nodeTestRunner.actionTestSetup(t);
fetchMock an instance of fetch-mock that you can use to simulate HTTP responses.
Let’s modify our “redirects to one-time offer” test:
await t.test("redirects to one-time offer", async () => {
const eligibilityEndpoint = "https://marketing.api/eligibility";
const action = auth0.mock.actions.postLogin({
secrets: { MARKETING_API_KEY: "secret-marketing-key" },
user: auth0.mock.user({ email: "ada@example.com" })
});
fetchMock.mock(eligibilityEndpoint, {
status: 200,
body: { isEligible: true }, // simulate JSON
});
await action.simulate(onExecutePostLogin);
ok(action.redirect, "Expected user to be redirected");
strictEqual(
action.redirect.url.toString(),
"https://marketing.party/offer",
"Expected to be redirected to the offer"
);
const apiCalls = fetchMock.calls();
strictEqual(apiCalls.length, 1, "Expected one API request");
const [url, request] = apiCalls[0]; // first API call
strictEqual(
url,
eligibilityEndpoint,
"Expected API request to be made to the eligibility endpoint"
);
strictEqual(
request.headers.Authorization,
"Bearer secret-marketing-key",
"Expected API key secret to be sent"
);
deepStrictEqual(
JSON.parse(request.body),
{ email: "ada@example.com" },
"Unexpected request body"
);
});
This test mocks the https://marketing.api/eligibility endpoint to simulate a JSON response of { "isEligible": true } and verifies that the user is redirected. We also check that the fetch request includes the right Authorization header and sends the user’s email correctly in the request.
We also want to add a test which tests what happens when they are not eligible:
await t.test("doesn't redirect to the offer if not eligible", async () => {
const action = auth0.mock.actions.postLogin();
fetchMock.mock(
"https://marketing.api/eligibility",
{ isEligible: false }
);
await action.simulate(onExecutePostLogin);
ok(!action.redirect, "Expected user not to be redirected");
});
There are other tests we can write here, such as when fetch fails or JSON.parse throws an exception. (Most of the time you’ll want to capture exceptions, as an uncaught exception in an Auth0 Action will prevent a user from signing in.) See the finished test suite.
For other examples, such as denying access, adding JWT claims, or prompting for MFA, see more examples on GitHub.
Working with NPM dependencies
If you need to work with other NPM modules, you’ll need to add them both to the Action (via the Auth0 editor’s “Dependencies” button) and to the package.json (e.g. npm install some-package@1.2.3 --save-dev).
Running tests in your CI/CD pipeline
To run tests in your CI/CD pipeline, you’ll need a Node environment that runs npm install and node --test.
For example, if your team uses GitHub, you can create a workflow in your Auth0 configuration repository’s .github/workflows/test-actions.yml like this one:
name: Test Auth0 Actions
on: [push]
jobs:
test:
runs-on: ubuntu-latest
# Assuming your package.json is in the `actions` directory
defaults:
run:
working-directory: ./actions
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
cache: npm
- run: npm ci
- run: node --test
Conclusion
With the tutorial above, you can now develop your Actions locally, keep them in version control, get changes peer reviewed on a Pull Request, and run them in your CI/CD pipeline. You can bring software best practices to one of your most critical systems.
Of course, unit tests do not always mirror reality. You’ll still want to verify some behaviour by testing manually in a pre-production environment. But they will give you confidence in your code, and help lower risk by creating more reliable, maintainable Actions.
Feedback welcome
Kilterset is an Auth0 partner focused on delivering high quality, robust, reliable solutions for Auth0. By open-sourcing our work, as well as building tools like our AI Assistant for Auth0 Actions we hope we can help the community and ecosystem do the same.
This library was developed to test real-world Actions, including those we publish in the Auth0 Marketplace. If you have bug reports, suggestions, or patches, please give us your feedback on GitHub or leave a comment below. And if you’d like to work with us or learn more about what we’re doing, you can follow us on LinkedIn.
Resources
- Full code and tests from this tutorial
- Auth0 Actions Testing library (NPM, GitHub)
- Auth0 Actions Flows and Triggers
- Node 18 test runner
- Node 18 Assertions
- AI Assistant for Auth0 Actions, which generates a test suite along with each solution
