Welcome, fellow developers! Today I want to present you a step-by-step technique on how to test Auth0’s custom actions and databases in Javascript. For those of you who don’t know Auth0, it’s an identity management platform that you can connect to your existing or new applications, and configure it to easily provide authentication and authorization mechanisms. It’s one of the easiest solutions for IAM nowadays.
You can check the reference repository if you just want to see the final result, but I encourage you to keep reading if you also want to understand the thought process. Here’s what we’ll learn:
- How to use
auth0-deploy-cli
to keep you tenant’s configuration, code, and settings in a local repo - How to create tests for your custom database
- How to apply TDD to create the tests above and implement the scripts
- How to use Spies instead of Mocks to test network calls to external services
- How to inject dependencies in database scripts
- How to use globally-defined objects for testing
- How to properly handle errors in Auth0
Without further ado, let’s get into it!
Auth0 Custom Database Scripts
So, what are Auth0’s custom database scripts? To be concise, these are scripts used to connect to an existing application’s database to perform the following actions:
- Create – This script will create a user in your database when someone signs up via Auth0
- Login – This script authenticates a user against the credentials stored in your database.
- Delete – Once a user is deleted via Auth0’s dashboard or Management API, this script ensures the user is also deleted in your database.
- Get User – This script determines whether a user with a given email exists. It’s executed when users change their password to determine if they exist.
- Verify – This script marks the current user’s email address as verified in your database
- Change Password – This script should change the password stored for the current user in your database.
These actions must be highly maintainable and reliable — after all, your application’s authorization and authentication flows will go through them. Creating unit tests for these custom database scripts is extremely valuable to ensure everything is working as expected while also serving as live documentation for IAM. In the next section, we’ll see what we need to start creating tests for our Auth0 Tenant scripts.
Pulling the tenant data
Before creating tests for our scripts, we need our tenant’s data and configuration in our local machines. To do so, we must install auth0-deploy-cli. This tool can download your tenant’s configuration and schema to local folders. Follow the steps below to configure it and pull your tenant’s data:
The steps below assume you already have an Auth0 tenant created.
- Create a new Database authentication method. We’ll use this as our example external database that Auth0 needs to connect to and synchronize with. Go to Authentication > Database > Create DB Connection to create it.
- Go to the Custom Database tab and toggle the
Use my own Database
switch. This will enable all the custom database action scripts.
- Go to the Custom Database tab and toggle the
- Install the CLI tool using your preferred package manager:
yarn add -D auth0-deploy-cli
- Create an Auth0 Application to represent the CLI tool access. To do so, go to the dashboard, Applications > Applications > Create Application > Machine to Machine.
- Select the Auth0 Management API as the authorized party. Then, you can select all the permissions for the purpose of this exercise – ideally, you’d go with the least-privileges strategy.
- Create a file named
config.json
in the root of your project, and put the following data into it:
{
"AUTH0_DOMAIN": "auth0-domain", // You can check this in the application you just created, under the `settings` tab
"AUTH0_CLIENT_ID": "auth0-deploy-application-client-id",
"AUTH0_CLIENT_SECRET": "auth0-deploy-application-client-secret",
"AUTH0_ALLOW_DELETE": false
}
// These values are used by the CLI to connect to your Auth0 tenant and potentially pull / push data.
- Create a script in your
package.json
file to pull changes from the tenant to your local file system:
{
"scripts": {
"a0deploy export -c config.json --format=yaml --output_folder=tenant"
}
}
- Execute the script with your package manager. Then, your directory structure will look like the following:
📦tenant
┣ 📂databases
┃ ┗ 📂Database // This folder represents the Database Authentication we've created
┃ ┃ ┣ 📜change_password.js
┃ ┃ ┣ 📜create.js
┃ ┃ ┣ 📜delete.js
┃ ┃ ┣ 📜get_user.js
┃ ┃ ┣ 📜login.js
┃ ┃ ┗ 📜verify.js
┣ 📂emailTemplates
┣ 📂hooks
┣ 📂pages
┣ 📂rules
┗ 📜tenant.yaml // This file stores all the tenant's settings and data
At this point, we’re ready to start creating tests for our database scripts. We’ll see how to do that in the next section.
Creating tests for database scripts
Finally, we have (almost) everything set up to start writing our tests – but before we do so, let’s make it clear that we need an example definition of an API that is a port to our external database. You can check out all the requirements in the Readme file, but let’s take care of a single endpoint for now, the signup:
POST /signup - used to create users
- Body:
- `email`: The user's email
- `password`: The user's password
- `passwordConfirmation`: The user's password confirmation
- Response:
- ✅ **Success**:
- **status**: 201
- **body**:
- `id`: The user's id
- ❌ **Error**:
- **status**: 400 | 500
- **body**:
- `error`: The error message
With that information, we’ll start defining which tests we need before implementing anything. Create a new file under /tenant/test/databases/create.test.js
. There, we’ll determine what needs to be done:
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
describe('Create user script', () => {
test.todo('successfully creates a user');
test.todo('calls axios post with correct parameters');
test.todo('throws validation error when user already exists');
test.todo('throws error with descriptive message when something goes wrong');
});
Notice that we’re using node’s native test module – the idea is to use the least number of dependencies, and node’s test runner has all features we need here – while also being way faster than jest.
To execute test files with node’s test runner, we need to add another script to our package.json
file:
{
"scripts": {
"test": "node --test"
}
}
Now, you can simply execute it like yarn test
and see the results. Remember, you’ll need Node’s LTS version to make it work!
So, we must pick one test case above and define its body. Then, we’ll implement the least amount of code that we need to make it pass – this is how we apply the famous Test-Driven Development (TDD). Let’s start with the first test, namely, “successfully creates a user”. This is the easiest one to begin with – if you look at the generated comments for the create.js
script, the expected result when the user is successfully created is that it should call the callback
function with null
:
// There are three ways this script can finish:
// 1. A user was successfully created
// callback(null);
// 2. This user already exists in your database
// callback(new ValidationError("user_exists", "my error message"));
// 3. Something went wrong while trying to reach your database
// callback(new Error("my error message"));
Considering that requirement now, let’s define the test body as follows:
const { test, describe, mock } = require('node:test');
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
const { create } = require('../../databases/Database/create');
describe('Create user script', () => {
test('successfully creates a user', async () => {
// Arrange
const user = makeUserData();
const callback = mock.fn();
// Act
await create(user, callback);
// Assert
assert.equal(callback.mock.callCount(), 1);
assert.deepEqual(callback.mock.calls[0].arguments, [null]);
});
test.todo('calls axios post with correct parameters');
test.todo('throws validation error when user already exists');
test.todo('throws error with descriptive message when something goes wrong');
});
function makeUserData() {
return {
email: 'moc.liam @resu',
password: randomUUID(),
tenant: 'test-tenant',
client_id: 'test-client-id',
connection: 'Database',
};
}
As you can see, the only expected behavior here is that our callback
function, created using node’s mock
utility, was called with null
argument. the makeUserData()
utility function returns an object with the interface the create
script expects.
Now, after executing this test and seeing it failing, we’ll change the source code to make it pass:
// You have to manually change the code below to explicitly export the `create` function,
// otherwise you can't import that function into the test file. It still works in Auth0, so no need to worry :)
module.exports.create = async function create(user, callback) {
// Auto-generated docs redacted
return callback(null);
};
The first test should pass now. Great! That’s our first step towards implementing this create
custom script. Before we move to the next ones, we can perform a slight improvement to our “Assert” block in our test code, which reads a bit weird now:
// Assert
assert.equal(callback.mock.callCount(), 1);
assert.deepEqual(callback.mock.calls[0].arguments, [null]);
We can improve the readability of this type of test by creating a new utility:
// mock-extended.js file
const { mock } = require('node:test');
const assert = require('node:assert/strict');
function mockFn() {
const mockedFunction = mock.fn();
mockedFunction.shouldHaveBeenCalledOnceWith = (expectedArgument) => {
assert.equal(mockedFunction.mock.callCount(), 1);
assert.deepEqual(
mockedFunction.mock.calls[0].arguments[0],
expectedArgument
);
};
return mockedFunction;
}
module.exports = {
mockFn,
};
The mock-extended
file above exposes a factory to create an enhanced node’s native mock
object. This object has an additional shouldHaveBeenCalledOnceWith(argument)
method to increase the test readability. Here’s how we can rewrite the test:
test('successfully creates a user', async () => {
// Arrange
const user = makeUserData();
const callback = mockFn();
// Act
await create(user, callback);
// Assert
callback.shouldHaveBeenCalledOnceWith(null);
});
Way more expressive, don’t you agree? The next step is to implement another todo
test, test.todo('calls axios post with correct parameters');
. This test will require more sophistication, so we’ll dive into it in the next section.
Creating and injecting Spies to communicate with external services
Mocking is a common practice when testing your application’s behavior when communicating with external services. Mocks are a particular type of Test Double, an object used to replace the production one for testing purposes. However, mocks in Javascript are not so great because they don’t provide a nice API for stubbing returned values or checking for called values (as you can notice with the callback
mock example, we had to create a new utility).
A more helpful approach when we want to override communication with external services is to create a Spy. A Spy is an object that follows the same API as the production one but returns canned data and stores what was called internally. This is useful to make the test code cleaner and readable while also encapsulating the details of how the stubbing process works underneath it. Moreover, you can reutilize the Spy definition across different tests.
So, let’s get back to what we needed for the new test:
test.todo('calls axios post with correct parameters');
This test aims to ensure we called axios.post
with the URL and body parameters as defined in the requirements:
-
URL:
POST /signup
-
Body:
email
,password
, andpasswordConfirmation
We’ll use an AxiosSpy class to test this with a nice fluent interface. However, to use this spy, we also need the create
script to accept an instance of axios
as an additional parameter, otherwise we can’t inject our spy:
module.exports.create = async function create(user, callback, axios = require('axios') {
// Auto-generated docs redacted
return callback(null);
};
The default value of this axios
instance will be used in the production environment. Now, let’s get back to the test:
const { test, describe } = require('node:test');
const { mockFn } = require('../utils/mock-extended');
const { AxiosSpy, HttpMethod } = require('../utils/axios.spy');
const { randomUUID } = require('node:crypto');
const { create } = require('../../databases/Database/create');
describe('Create user script', () => {
test('successfully creates a user', async () => {
// Arrange
const user = makeUserData();
const callback = mockFn();
const axiosSpy = new AxiosSpy();
// Act
await create(user, callback, axiosSpy);
// Assert
callback.shouldHaveBeenCalledOnceWith(null);
});
test('calls axios post with correct parameters', async () => {
// Arrange
const user = makeUserData();
const callback = mockFn();
const axiosSpy = new AxiosSpy();
// Act
await create(user, callback, axiosSpy);
// Assert
axiosSpy
.shouldHaveSentNumberOfRequests(1)
.withUrl(new URL('https://backend/signup'))
.withMethod(HttpMethod.Post)
.withBody({
email: user.email,
password: user.password,
passwordConfirmation: user.password,
});
});
test.todo('throws validation error when user already exists');
test.todo('throws error with descriptive message when something goes wrong');
});
function makeUserData() {
return {
email: 'moc.liam @resu',
password: randomUUID(),
tenant: 'test-tenant',
client_id: 'test-client-id',
connection: 'Database',
};
}
Notice that we also had to change the first test case to inject the AxiosSpy
instance, otherwise, it would try to use the actual axios
object. We execute the test to see it failing, and then we can add the missing implementation:
module.exports.create = async function create(
user,
callback,
axios = require('axios')
) {
const { email, password } = user;
await axios.post(new URL('https://backend/signup'), {
email,
password,
passwordConfirmation: password,
});
return callback(null);
};
This should suffice to make the test pass, so we’ll dive into the next step: using global objects.
Using global references in tests
Checking the next test we need to implement you’ll notice it requires a globally defined object:
test.todo('throws validation error when user already exists');
This ValidationError
is an internal error created by Auth0’s runtime that you can use without importing it. The create
function template comes with the following comment:
// 2. This user already exists in your database
// callback(new ValidationError("user_exists", "my error message"));
So, how do we make it available for our tests? Simple enough, just define a ValidationError
class in the test file global context:
global.ValidationError = class ValidationError extends Error {
constructor(code, message) {
super(message);
this.code = code;
}
};
Now, we can write down the test specification:
test('throws validation error when user already exists', async () => {
// Arrange
const user = makeUserData();
const callback = mockFn();
const axiosSpy = new AxiosSpy();
axiosSpy.stubResponseFor(
HttpMethod.Post,
new URL('https://backend/signup'),
{
status: 404,
body: {
error: 'user already exists',
},
}
);
// Act
await create(user, callback, axiosSpy);
// Assert
callback.shouldHaveBeenCalledOnceWith(
new ValidationError('user_exists', 'User already exists')
);
});
We can execute this test to guarantee it’s failing for the right reason now: the callback
function wasn’t called with that validation error. Here’s what we need to change to make it pass:
module.exports.create = async function create(
user,
callback,
axios = require('axios')
) {
const { email, password } = user;
try {
await axios.post(new URL('https://backend/signup'), {
email,
password,
passwordConfirmation: password,
});
} catch (error) {
return callback(new ValidationError('user_exists', 'User already exists'));
}
return callback(null);
};
Now the test works again . We can use this same strategy for other globally-defined objects in Auth0, such as the configuration
object used to store sensitive information.
The final step now is to make sure the last test also passes. It should be straightforward, as we don’t need any additional constructs:
test('throws error with descriptive message when something goes wrong', async () => {
// Arrange
const user = makeUserData();
const callback = mockFn();
const axiosSpy = new AxiosSpy();
axiosSpy.stubUnexpectedErrorFor(
HttpMethod.Post,
new URL('https://backend/signup'),
new Error('Network error')
);
// Act
await create(user, callback, axiosSpy);
// Assert
callback.shouldHaveBeenCalledOnceWith(
new Error(
'Something went wrong while trying to sign up user (Network error)'
)
);
});
This test ensures we handle unexpected errors properly, wrapping their message into our own. Here’s the implementation to make it pass:
module.exports.create = async function create(
user,
callback,
axios = require('axios')
) {
const { email, password } = user;
try {
await axios.post(new URL('https://backend/signup'), {
email,
password,
passwordConfirmation: password,
});
} catch (error) {
if (error.response?.status === 404)
return callback(new ValidationError('user_exists', 'User already exists'));
return callback(
new Error(
`Something went wrong while trying to sign up user (${error.message})`
)
);
}
return callback(null);
};
Now all unit tests are passing, and we’ve completed the create
script.
Conclusion
Automated tests are undeniably essential to ensure our code’s maintainability. Without proper tests, we can’t trust any code changes and have to rely on expensive manual tests. Auth0’s custom scripts aren’t an exception—in fact, since they play such an important role in our system, they should be thoroughly tested. This article explained how to do it while also teaching a few tips and tricks.
If readers show enough interest, the next article in this series will discuss how to perform integration tests and use them in your CI. I hope this one has helped you start testing Auth0 scripts.
See you in the next one!