> ## Documentation Index
> Fetch the complete documentation index at: https://auth0.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Unit Test an Action That Renders a Form

> Unit tests for a Post Login action that renders a marketing consent Form and stores the submitted answer, denying access when no answer is submitted, using Jest, Mocha, and Node.js Test Runner in JavaScript and TypeScript.

## Project

Each example separates the action's source code under `src/` from its unit tests under `test/`, following the same structure across JavaScript and TypeScript projects.

**JavaScript**

**/**

**src**

**test**

**TypeScript**

**/**

**src**

**test**

**test-utils**

## Action

The following Post Login action renders a marketing consent Form to users who haven't answered it yet, then handles the submitted response on the following login attempt: denying access if the Form was skipped, or storing the consent choice in `user_metadata` otherwise.

**JavaScript**

```js title="mock-form-render.js" theme={null}
/** @import {Event, PostLoginAPI} from "@auth0/actions/post-login/v3" */

const CONSENT_FORM_ID = 'marketing-consent-form';

/**
* Handler that will be called during the execution of a PostLogin flow.
* Renders a custom Form asking for marketing consent when the user hasn't
* answered it yet. `api.prompt.render` cannot be combined with
* `api.redirect.sendUserTo` in the same execution - pick one per action.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onExecutePostLogin = async (event, api) => {
  const hasAnsweredConsent = typeof event.user.user_metadata.marketing_consent === 'boolean';

  if (hasAnsweredConsent) {
    return;
  }

  api.prompt.render(CONSENT_FORM_ID, {
    vars: { firstName: event.user.given_name ?? event.user.name },
  });
};

/**
* Handler that will be called when the user submits the Form rendered by
* onExecutePostLogin.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onContinuePostLogin = async (event, api) => {
  const consent = event.prompt?.fields?.consent;

  if (typeof consent !== 'boolean') {
    api.access.deny('Marketing consent response was not submitted.');
    return;
  }

  api.user.setUserMetadata('marketing_consent', consent);
};

```

**TypeScript**

```ts title="mock-form-render.ts" theme={null}
import type { Event, PostLoginAPI } from '@auth0/actions/post-login/v3';

const CONSENT_FORM_ID = 'marketing-consent-form';

/**
* Handler that will be called during the execution of a PostLogin flow.
* Renders a custom Form asking for marketing consent when the user hasn't
* answered it yet. `api.prompt.render` cannot be combined with
* `api.redirect.sendUserTo` in the same execution - pick one per action.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onExecutePostLogin = async (event: Event, api: PostLoginAPI) => {
  const hasAnsweredConsent = typeof event.user.user_metadata.marketing_consent === 'boolean';

  if (hasAnsweredConsent) {
    return;
  }

  api.prompt.render(CONSENT_FORM_ID, {
    vars: { firstName: event.user.given_name ?? event.user.name },
  });
};

/**
* Handler that will be called when the user submits the Form rendered by
* onExecutePostLogin.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onContinuePostLogin = async (event: Event, api: PostLoginAPI) => {
  const consent = event.prompt?.fields?.consent;

  if (typeof consent !== 'boolean') {
    api.access.deny('Marketing consent response was not submitted.');
    return;
  }

  api.user.setUserMetadata('marketing_consent', consent);
};

```

## Unit Test

The unit tests mock the `event` and `api` objects to verify the Form renders only when consent hasn't been answered, and that both accepted and declined consent submissions are stored correctly while missing submissions are denied.

> **Jest**
>
> **JavaScript**
>
> ```js title="mock-form-render.spec.js" theme={null}
> const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
> const path = require('path');
>
> const DIRNAME = path.dirname('../');
> const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.js');
> const CONSENT_FORM_ID = 'marketing-consent-form';
>
> describe('onExecutePostLogin', () => {
>   let loader;
>   let event;
>   let api;
>
>   beforeEach(async () => {
>     jest.resetAllMocks();
>     loader = await loadAction(ACTION_PATH);
>     [event, api] = getDefaultArguments();
>     jest.spyOn(api.prompt, 'render');
>   });
>
>   afterEach(() => {
>     jest.resetAllMocks();
>   });
>
>   it('renders the consent form when the user has not answered yet', async () => {
>     await loader.execute('onExecutePostLogin', event, api);
>
>     expect(api.prompt.render).toHaveBeenCalledWith(CONSENT_FORM_ID, {
>       vars: { firstName: event.user.given_name },
>     });
>   });
>
>   it('skips rendering when the user has already answered', async () => {
>     event.user.user_metadata.marketing_consent = true;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     expect(api.prompt.render).not.toHaveBeenCalled();
>   });
>
>   it('skips rendering when the user has already declined', async () => {
>     event.user.user_metadata.marketing_consent = false;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     expect(api.prompt.render).not.toHaveBeenCalled();
>   });
> });
>
> describe('onContinuePostLogin', () => {
>   let loader;
>   let event;
>   let api;
>
>   beforeEach(async () => {
>     jest.resetAllMocks();
>     loader = await loadAction(ACTION_PATH);
>     [event, api] = getDefaultArguments();
>     jest.spyOn(api.access, 'deny');
>     jest.spyOn(api.user, 'setUserMetadata');
>   });
>
>   afterEach(() => {
>     jest.resetAllMocks();
>   });
>
>   it('denies access when the form response is missing', async () => {
>     await loader.execute('onContinuePostLogin', event, api);
>
>     expect(api.access.deny).toHaveBeenCalledWith('Marketing consent response was not submitted.');
>     expect(api.user.setUserMetadata).not.toHaveBeenCalled();
>   });
>
>   it('stores an accepted consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = true;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     expect(api.user.setUserMetadata).toHaveBeenCalledWith('marketing_consent', true);
>     expect(api.access.deny).not.toHaveBeenCalled();
>   });
>
>   it('stores a declined consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = false;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     expect(api.user.setUserMetadata).toHaveBeenCalledWith('marketing_consent', false);
>     expect(api.access.deny).not.toHaveBeenCalled();
>   });
> });
>
> ```
>
> Add the test dependencies to `package.json`:
>
> ```json title="package.json" theme={null}
> {
>   "name": "actions-npm-example-js-jest",
>   "version": "1.0.0",
>   "description": "Auth0 Actions unit testing example using Jest",
>   "license": "MIT",
>   "author": "Auth0",
>   "type": "commonjs",
>   "main": "module-usage.js",
>   "scripts": {
>     "test": "jest"
>   },
>   "devDependencies": {
>     "@auth0/actions": "^0.33.0",
>     "jest": "^30.4.2"
>   },
>   "jest": {
>     "testEnvironment": "node"
>   }
> }
>
> ```
>
> Configure `jsconfig.json` so the `actions:` import alias resolves to `src/`:
>
> ```json title="jsconfig.json" theme={null}
> {
>   "compilerOptions": {
>     "target": "ES2020",
>     "module": "commonjs",
>     "checkJs": false,
>     "baseUrl": ".",
>     "paths": {
>       "actions:*": [
>         "src/*"
>       ]
>     }
>   },
>   "include": [
>     "src/**/*.js"
>   ]
> }
>
> ```
>
> **TypeScript**
>
> ```ts title="mock-form-render.test.ts" theme={null}
> const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
> const path = require('path');
> const { compileActionModules } = require('./test-utils/load-compiled-action');
>
> const DIRNAME = path.dirname('../');
> const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.ts');
> const CONSENT_FORM_ID = 'marketing-consent-form';
>
> describe('onExecutePostLogin', () => {
>   let loader: any;
>   let event: any;
>   let api: any;
>
>   beforeEach(async () => {
>     jest.resetAllMocks();
>     const { compiledActionPath } = compileActionModules(ACTION_PATH);
>     loader = await loadAction(compiledActionPath);
>     [event, api] = getDefaultArguments();
>     jest.spyOn(api.prompt, 'render');
>   });
>
>   afterEach(() => {
>     jest.resetAllMocks();
>   });
>
>   it('renders the consent form when the user has not answered yet', async () => {
>     await loader.execute('onExecutePostLogin', event, api);
>
>     expect(api.prompt.render).toHaveBeenCalledWith(CONSENT_FORM_ID, {
>       vars: { firstName: event.user.given_name },
>     });
>   });
>
>   it('skips rendering when the user has already answered', async () => {
>     event.user.user_metadata.marketing_consent = true;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     expect(api.prompt.render).not.toHaveBeenCalled();
>   });
>
>   it('skips rendering when the user has already declined', async () => {
>     event.user.user_metadata.marketing_consent = false;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     expect(api.prompt.render).not.toHaveBeenCalled();
>   });
> });
>
> describe('onContinuePostLogin', () => {
>   let loader: any;
>   let event: any;
>   let api: any;
>
>   beforeEach(async () => {
>     jest.resetAllMocks();
>     const { compiledActionPath } = compileActionModules(ACTION_PATH);
>     loader = await loadAction(compiledActionPath);
>     [event, api] = getDefaultArguments();
>     jest.spyOn(api.access, 'deny');
>     jest.spyOn(api.user, 'setUserMetadata');
>   });
>
>   afterEach(() => {
>     jest.resetAllMocks();
>   });
>
>   it('denies access when the form response is missing', async () => {
>     await loader.execute('onContinuePostLogin', event, api);
>
>     expect(api.access.deny).toHaveBeenCalledWith('Marketing consent response was not submitted.');
>     expect(api.user.setUserMetadata).not.toHaveBeenCalled();
>   });
>
>   it('stores an accepted consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = true;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     expect(api.user.setUserMetadata).toHaveBeenCalledWith('marketing_consent', true);
>     expect(api.access.deny).not.toHaveBeenCalled();
>   });
>
>   it('stores a declined consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = false;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     expect(api.user.setUserMetadata).toHaveBeenCalledWith('marketing_consent', false);
>     expect(api.access.deny).not.toHaveBeenCalled();
>   });
> });
>
> ```
>
> This test helper compiles the TypeScript action to a temporary file so it can be loaded at runtime:
>
> ```ts title="load-compiled-action.ts" theme={null}
> import * as fs from 'fs';
> import * as os from 'os';
> import * as path from 'path';
> import * as ts from 'typescript';
>
> export interface ModuleToCompile {
>   name: string;
>   filename: string;
> }
>
> function transpileToTemp(sourcePath: string): string {
>   const source = fs.readFileSync(sourcePath, 'utf8');
>   const { outputText } = ts.transpileModule(source, {
>     compilerOptions: {
>       module: ts.ModuleKind.CommonJS,
>       target: ts.ScriptTarget.ES2020,
>       esModuleInterop: true,
>     },
>   });
>
>   const tempPath = path.join(
>     os.tmpdir(),
>     `${path.basename(sourcePath, path.extname(sourcePath))}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
>   );
>   fs.writeFileSync(tempPath, outputText);
>   return tempPath;
> }
>
> /**
>  * loadAction() (from @auth0/actions/*\/test) reads its target file from disk and
>  * runs it via vm.compileFunction, so it never goes through ts-node/Vitest's own
>  * TS transform. Action sources (and any actions:-registered modules) must be
>  * transpiled to plain JS on disk first.
>  */
> export function compileActionModules(actionPath: string, modules: ModuleToCompile[] = []) {
>   const compiledActionPath = transpileToTemp(actionPath);
>   const compiledModules = modules.map((m) => ({
>     name: m.name,
>     filename: transpileToTemp(m.filename),
>   }));
>
>   return { compiledActionPath, compiledModules };
> }
>
> ```
>
> Add the test dependencies to `package.json`:
>
> ```json title="package.json" theme={null}
> {
>   "name": "actions-npm-example-ts-jest",
>   "version": "1.0.0",
>   "description": "Auth0 Actions unit testing example using Jest and TypeScript",
>   "main": "example.ts",
>   "scripts": {
>     "test": "jest"
>   },
>   "author": "Auth0",
>   "license": "MIT",
>   "devDependencies": {
>     "@auth0/actions": "^0.33.0",
>     "@types/jest": "^29.5.12",
>     "@types/node": "22.14.0",
>     "jest": "^29.7.0",
>     "ts-jest": "^29.1.2",
>     "typescript": "^5.9.2"
>   }
> }
>
> ```
>
> Configure Jest to compile TypeScript with `jest.config.js`:
>
> ```js title="jest.config.js" theme={null}
> module.exports = {
>   preset: 'ts-jest',
>   testEnvironment: 'node',
> };
> ```
>
> Configure `tsconfig.json` so the `actions:` import alias resolves to `src/`:
>
> ```json title="tsconfig.json" theme={null}
> {
>   "compilerOptions": {
>     "target": "ES2020",
>     "module": "NodeNext",
>     "moduleResolution": "nodenext",
>     "esModuleInterop": true,
>     "allowSyntheticDefaultImports": true,
>     "strict": true,
>     "outDir": "dist",
>     "declaration": true,
>     "sourceMap": true,
>     "allowJs": true,
>     "checkJs": false,
>     "resolveJsonModule": true,
>     "skipLibCheck": true,
>     "forceConsistentCasingInFileNames": true,
>     "isolatedModules": true,
>     "noEmit": true,
>     "paths": {
>       "actions:*": [
>         "./src/*"
>       ]
>     }
>   },
>   "exclude": [
>     "node_modules",
>     "dist"
>   ],
>   "include": [
>     "**/*.ts"
>   ],
>   "ts-node": {
>     "transpileOnly": true
>   }
> }
>
> ```

> **Mocha**
>
> **JavaScript**
>
> ```js title="mock-form-render.spec.js" theme={null}
> const sinon = require('sinon');
> const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
> const path = require('path');
>
> const DIRNAME = path.dirname('../');
> const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.js');
> const CONSENT_FORM_ID = 'marketing-consent-form';
>
> describe('onExecutePostLogin', () => {
>   let loader;
>   let event;
>   let api;
>
>   beforeEach(async () => {
>     loader = await loadAction(ACTION_PATH);
>     [event, api] = getDefaultArguments();
>     sinon.spy(api.prompt, 'render');
>   });
>
>   afterEach(() => {
>     sinon.restore();
>   });
>
>   it('renders the consent form when the user has not answered yet', async () => {
>     await loader.execute('onExecutePostLogin', event, api);
>
>     sinon.assert.calledWith(api.prompt.render, CONSENT_FORM_ID, {
>       vars: { firstName: event.user.given_name },
>     });
>   });
>
>   it('skips rendering when the user has already answered', async () => {
>     event.user.user_metadata.marketing_consent = true;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     sinon.assert.notCalled(api.prompt.render);
>   });
>
>   it('skips rendering when the user has already declined', async () => {
>     event.user.user_metadata.marketing_consent = false;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     sinon.assert.notCalled(api.prompt.render);
>   });
> });
>
> describe('onContinuePostLogin', () => {
>   let loader;
>   let event;
>   let api;
>
>   beforeEach(async () => {
>     loader = await loadAction(ACTION_PATH);
>     [event, api] = getDefaultArguments();
>     sinon.spy(api.access, 'deny');
>     sinon.spy(api.user, 'setUserMetadata');
>   });
>
>   afterEach(() => {
>     sinon.restore();
>   });
>
>   it('denies access when the form response is missing', async () => {
>     await loader.execute('onContinuePostLogin', event, api);
>
>     sinon.assert.calledWith(api.access.deny, 'Marketing consent response was not submitted.');
>     sinon.assert.notCalled(api.user.setUserMetadata);
>   });
>
>   it('stores an accepted consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = true;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     sinon.assert.calledWith(api.user.setUserMetadata, 'marketing_consent', true);
>     sinon.assert.notCalled(api.access.deny);
>   });
>
>   it('stores a declined consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = false;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     sinon.assert.calledWith(api.user.setUserMetadata, 'marketing_consent', false);
>     sinon.assert.notCalled(api.access.deny);
>   });
> });
>
> ```
>
> Add the test dependencies to `package.json`:
>
> ```json title="package.json" theme={null}
> {
>   "name": "actions-npm-example-js-mocha",
>   "version": "1.0.0",
>   "description": "Auth0 Actions unit testing example using Mocha",
>   "license": "MIT",
>   "author": "Auth0",
>   "type": "commonjs",
>   "main": "module-usage.js",
>   "scripts": {
>     "test": "mocha"
>   },
>   "devDependencies": {
>     "@auth0/actions": "^0.33.0",
>     "chai": "^4.5.0",
>     "mocha": "^11.0.0",
>     "sinon": "^19.0.0"
>   }
> }
>
> ```
>
> Configure Mocha with `.mocharc.json`:
>
> ```json title=".mocharc.json" theme={null}
> {
>   "spec": "src/**/*.spec.js"
> }
>
> ```
>
> Configure `jsconfig.json` so the `actions:` import alias resolves to `src/`:
>
> ```json title="jsconfig.json" theme={null}
> {
>   "compilerOptions": {
>     "target": "ES2020",
>     "module": "commonjs",
>     "checkJs": false,
>     "baseUrl": ".",
>     "paths": {
>       "actions:*": [
>         "src/*"
>       ]
>     }
>   },
>   "include": [
>     "src/**/*.js"
>   ]
> }
>
> ```
>
> **TypeScript**
>
> ```ts title="mock-form-render.test.ts" theme={null}
> import * as path from 'path';
> import sinon from 'sinon';
> import { compileActionModules } from './test-utils/load-compiled-action';
>
> const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
>
> const DIRNAME = path.dirname('../');
> const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.ts');
> const CONSENT_FORM_ID = 'marketing-consent-form';
>
> describe('onExecutePostLogin', () => {
>   let loader: any;
>   let event: any;
>   let api: any;
>
>   beforeEach(async () => {
>     const { compiledActionPath } = compileActionModules(ACTION_PATH);
>     loader = await loadAction(compiledActionPath);
>     [event, api] = getDefaultArguments();
>     sinon.spy(api.prompt, 'render');
>   });
>
>   afterEach(() => {
>     sinon.restore();
>   });
>
>   it('renders the consent form when the user has not answered yet', async () => {
>     await loader.execute('onExecutePostLogin', event, api);
>
>     sinon.assert.calledWith(api.prompt.render, CONSENT_FORM_ID, {
>       vars: { firstName: event.user.given_name },
>     });
>   });
>
>   it('skips rendering when the user has already answered', async () => {
>     event.user.user_metadata.marketing_consent = true;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     sinon.assert.notCalled(api.prompt.render);
>   });
>
>   it('skips rendering when the user has already declined', async () => {
>     event.user.user_metadata.marketing_consent = false;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     sinon.assert.notCalled(api.prompt.render);
>   });
> });
>
> describe('onContinuePostLogin', () => {
>   let loader: any;
>   let event: any;
>   let api: any;
>
>   beforeEach(async () => {
>     const { compiledActionPath } = compileActionModules(ACTION_PATH);
>     loader = await loadAction(compiledActionPath);
>     [event, api] = getDefaultArguments();
>     sinon.spy(api.access, 'deny');
>     sinon.spy(api.user, 'setUserMetadata');
>   });
>
>   afterEach(() => {
>     sinon.restore();
>   });
>
>   it('denies access when the form response is missing', async () => {
>     await loader.execute('onContinuePostLogin', event, api);
>
>     sinon.assert.calledWith(api.access.deny, 'Marketing consent response was not submitted.');
>     sinon.assert.notCalled(api.user.setUserMetadata);
>   });
>
>   it('stores an accepted consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = true;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     sinon.assert.calledWith(api.user.setUserMetadata, 'marketing_consent', true);
>     sinon.assert.notCalled(api.access.deny);
>   });
>
>   it('stores a declined consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = false;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     sinon.assert.calledWith(api.user.setUserMetadata, 'marketing_consent', false);
>     sinon.assert.notCalled(api.access.deny);
>   });
> });
>
> ```
>
> This test helper compiles the TypeScript action to a temporary file so it can be loaded at runtime:
>
> ```ts title="load-compiled-action.ts" theme={null}
> import * as fs from 'fs';
> import * as os from 'os';
> import * as path from 'path';
> import * as ts from 'typescript';
>
> export interface ModuleToCompile {
>   name: string;
>   filename: string;
> }
>
> function transpileToTemp(sourcePath: string): string {
>   const source = fs.readFileSync(sourcePath, 'utf8');
>   const { outputText } = ts.transpileModule(source, {
>     compilerOptions: {
>       module: ts.ModuleKind.CommonJS,
>       target: ts.ScriptTarget.ES2020,
>       esModuleInterop: true,
>     },
>   });
>
>   const tempPath = path.join(
>     os.tmpdir(),
>     `${path.basename(sourcePath, path.extname(sourcePath))}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
>   );
>   fs.writeFileSync(tempPath, outputText);
>   return tempPath;
> }
>
> /**
>  * loadAction() (from @auth0/actions/*\/test) reads its target file from disk and
>  * runs it via vm.compileFunction, so it never goes through ts-node/Vitest's own
>  * TS transform. Action sources (and any actions:-registered modules) must be
>  * transpiled to plain JS on disk first.
>  */
> export function compileActionModules(actionPath: string, modules: ModuleToCompile[] = []) {
>   const compiledActionPath = transpileToTemp(actionPath);
>   const compiledModules = modules.map((m) => ({
>     name: m.name,
>     filename: transpileToTemp(m.filename),
>   }));
>
>   return { compiledActionPath, compiledModules };
> }
>
> ```
>
> Add the test dependencies to `package.json`:
>
> ```json title="package.json" theme={null}
> {
>   "name": "actions-npm-example-ts-mocha",
>   "version": "1.0.0",
>   "description": "Auth0 Actions unit testing example using Mocha and TypeScript",
>   "license": "MIT",
>   "author": "Auth0",
>   "scripts": {
>     "test": "NODE_OPTIONS=--no-experimental-strip-types mocha"
>   },
>   "devDependencies": {
>     "@auth0/actions": "^0.33.0",
>     "@types/chai": "^4.3.16",
>     "@types/mocha": "^10.0.6",
>     "@types/node": "22.14.0",
>     "@types/sinon": "^17.0.3",
>     "chai": "^4.5.0",
>     "mocha": "^11.0.0",
>     "sinon": "^19.0.0",
>     "ts-node": "^10.9.2",
>     "typescript": "^5.9.2"
>   }
> }
>
> ```
>
> Configure Mocha with `.mocharc.json`:
>
> ```json title=".mocharc.json" theme={null}
> {
>   "require": "ts-node/register",
>   "extension": ["ts"],
>   "spec": "src/**/*.test.ts"
> }
>
> ```
>
> Configure `tsconfig.json` so the `actions:` import alias resolves to `src/`:
>
> ```json title="tsconfig.json" theme={null}
> {
>   "compilerOptions": {
>     "target": "ES2020",
>     "module": "NodeNext",
>     "moduleResolution": "nodenext",
>     "esModuleInterop": true,
>     "allowSyntheticDefaultImports": true,
>     "strict": true,
>     "outDir": "dist",
>     "declaration": true,
>     "sourceMap": true,
>     "allowJs": true,
>     "checkJs": false,
>     "resolveJsonModule": true,
>     "skipLibCheck": true,
>     "forceConsistentCasingInFileNames": true,
>     "isolatedModules": true,
>     "noEmit": true,
>     "paths": {
>       "actions:*": [
>         "./src/*"
>       ]
>     }
>   },
>   "exclude": [
>     "node_modules",
>     "dist"
>   ],
>   "include": [
>     "**/*.ts"
>   ],
>   "ts-node": {
>     "transpileOnly": true
>   }
> }
>
> ```

> **Node.js Test Runner**
>
> **JavaScript**
>
> ```js title="mock-form-render.spec.js" theme={null}
> const assert = require('node:assert');
> const { describe, it, beforeEach, afterEach, mock } = require('node:test');
> const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
> const path = require('path');
>
> const DIRNAME = path.dirname('../');
> const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.js');
> const CONSENT_FORM_ID = 'marketing-consent-form';
>
> describe('onExecutePostLogin', () => {
>   let loader;
>   let event;
>   let api;
>
>   beforeEach(async () => {
>     loader = await loadAction(ACTION_PATH);
>     [event, api] = getDefaultArguments();
>     mock.method(api.prompt, 'render');
>   });
>
>   afterEach(() => {
>     mock.reset();
>   });
>
>   it('renders the consent form when the user has not answered yet', async () => {
>     await loader.execute('onExecutePostLogin', event, api);
>
>     assert.deepEqual(api.prompt.render.mock.calls[0].arguments, [CONSENT_FORM_ID, {
>       vars: { firstName: event.user.given_name },
>     }]);
>   });
>
>   it('skips rendering when the user has already answered', async () => {
>     event.user.user_metadata.marketing_consent = true;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     assert.strictEqual(api.prompt.render.mock.calls.length, 0);
>   });
>
>   it('skips rendering when the user has already declined', async () => {
>     event.user.user_metadata.marketing_consent = false;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     assert.strictEqual(api.prompt.render.mock.calls.length, 0);
>   });
> });
>
> describe('onContinuePostLogin', () => {
>   let loader;
>   let event;
>   let api;
>
>   beforeEach(async () => {
>     loader = await loadAction(ACTION_PATH);
>     [event, api] = getDefaultArguments();
>     mock.method(api.access, 'deny');
>     mock.method(api.user, 'setUserMetadata');
>   });
>
>   afterEach(() => {
>     mock.reset();
>   });
>
>   it('denies access when the form response is missing', async () => {
>     await loader.execute('onContinuePostLogin', event, api);
>
>     assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Marketing consent response was not submitted.']);
>     assert.strictEqual(api.user.setUserMetadata.mock.calls.length, 0);
>   });
>
>   it('stores an accepted consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = true;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     assert.deepEqual(api.user.setUserMetadata.mock.calls[0].arguments, ['marketing_consent', true]);
>     assert.strictEqual(api.access.deny.mock.calls.length, 0);
>   });
>
>   it('stores a declined consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = false;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     assert.deepEqual(api.user.setUserMetadata.mock.calls[0].arguments, ['marketing_consent', false]);
>     assert.strictEqual(api.access.deny.mock.calls.length, 0);
>   });
> });
>
> ```
>
> Add the test dependencies to `package.json`:
>
> ```json title="package.json" theme={null}
> {
>   "name": "actions-npm-example-js-node-test",
>   "version": "1.0.0",
>   "description": "Auth0 Actions unit testing example using the Node.js built-in test runner",
>   "license": "MIT",
>   "author": "Auth0",
>   "type": "commonjs",
>   "main": "module-usage.js",
>   "scripts": {
>     "test": "node --test src/*.spec.js"
>   },
>   "devDependencies": {
>     "@auth0/actions": "^0.33.0"
>   }
> }
>
> ```
>
> Configure `jsconfig.json` so the `actions:` import alias resolves to `src/`:
>
> ```json title="jsconfig.json" theme={null}
> {
>   "compilerOptions": {
>     "target": "ES2020",
>     "module": "commonjs",
>     "checkJs": false,
>     "baseUrl": ".",
>     "paths": {
>       "actions:*": [
>         "src/*"
>       ]
>     }
>   },
>   "include": [
>     "src/**/*.js"
>   ]
> }
>
> ```
>
> **TypeScript**
>
> ```ts title="mock-form-render.test.ts" theme={null}
> const assert = require('node:assert');
> const { describe, it, beforeEach, afterEach, mock } = require('node:test');
> const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
> const path = require('path');
> const { compileActionModules } = require('./test-utils/load-compiled-action.ts');
>
> const DIRNAME = path.dirname('../');
> const ACTION_PATH = path.resolve(DIRNAME, './src/mock-form-render.ts');
> const CONSENT_FORM_ID = 'marketing-consent-form';
>
> describe('onExecutePostLogin', () => {
>   let loader;
>   let event;
>   let api;
>
>   beforeEach(async () => {
>     const { compiledActionPath } = compileActionModules(ACTION_PATH);
>     loader = await loadAction(compiledActionPath);
>     [event, api] = getDefaultArguments();
>     mock.method(api.prompt, 'render');
>   });
>
>   afterEach(() => {
>     mock.reset();
>   });
>
>   it('renders the consent form when the user has not answered yet', async () => {
>     await loader.execute('onExecutePostLogin', event, api);
>
>     assert.deepEqual(api.prompt.render.mock.calls[0].arguments, [CONSENT_FORM_ID, {
>       vars: { firstName: event.user.given_name },
>     }]);
>   });
>
>   it('skips rendering when the user has already answered', async () => {
>     event.user.user_metadata.marketing_consent = true;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     assert.strictEqual(api.prompt.render.mock.calls.length, 0);
>   });
>
>   it('skips rendering when the user has already declined', async () => {
>     event.user.user_metadata.marketing_consent = false;
>
>     await loader.execute('onExecutePostLogin', event, api);
>
>     assert.strictEqual(api.prompt.render.mock.calls.length, 0);
>   });
> });
>
> describe('onContinuePostLogin', () => {
>   let loader;
>   let event;
>   let api;
>
>   beforeEach(async () => {
>     const { compiledActionPath } = compileActionModules(ACTION_PATH);
>     loader = await loadAction(compiledActionPath);
>     [event, api] = getDefaultArguments();
>     mock.method(api.access, 'deny');
>     mock.method(api.user, 'setUserMetadata');
>   });
>
>   afterEach(() => {
>     mock.reset();
>   });
>
>   it('denies access when the form response is missing', async () => {
>     await loader.execute('onContinuePostLogin', event, api);
>
>     assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Marketing consent response was not submitted.']);
>     assert.strictEqual(api.user.setUserMetadata.mock.calls.length, 0);
>   });
>
>   it('stores an accepted consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = true;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     assert.deepEqual(api.user.setUserMetadata.mock.calls[0].arguments, ['marketing_consent', true]);
>     assert.strictEqual(api.access.deny.mock.calls.length, 0);
>   });
>
>   it('stores a declined consent answer submitted by the form', async () => {
>     event.prompt.fields.consent = false;
>
>     await loader.execute('onContinuePostLogin', event, api);
>
>     assert.deepEqual(api.user.setUserMetadata.mock.calls[0].arguments, ['marketing_consent', false]);
>     assert.strictEqual(api.access.deny.mock.calls.length, 0);
>   });
> });
>
> ```
>
> This test helper compiles the TypeScript action to a temporary file so it can be loaded at runtime:
>
> ```ts title="load-compiled-action.ts" theme={null}
> const fs = require('fs');
> const os = require('os');
> const path = require('path');
> const ts = require('typescript');
>
> interface ModuleToCompile {
>   name: string;
>   filename: string;
> }
>
> function transpileToTemp(sourcePath: string): string {
>   const source = fs.readFileSync(sourcePath, 'utf8');
>   const { outputText } = ts.transpileModule(source, {
>     compilerOptions: {
>       module: ts.ModuleKind.CommonJS,
>       target: ts.ScriptTarget.ES2020,
>       esModuleInterop: true,
>     },
>   });
>
>   const tempPath = path.join(
>     os.tmpdir(),
>     `${path.basename(sourcePath, path.extname(sourcePath))}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
>   );
>   fs.writeFileSync(tempPath, outputText);
>   return tempPath;
> }
>
> /**
>  * loadAction() (from @auth0/actions/*\/test) reads its target file from disk and
>  * runs it via vm.compileFunction, so it never goes through node's native TS type
>  * stripping. Action sources (and any actions:-registered modules) must be
>  * transpiled to plain JS on disk first.
>  */
> exports.compileActionModules = function compileActionModules(actionPath: string, modules: ModuleToCompile[] = []) {
>   const compiledActionPath = transpileToTemp(actionPath);
>   const compiledModules = modules.map((m: ModuleToCompile) => ({
>     name: m.name,
>     filename: transpileToTemp(m.filename),
>   }));
>
>   return { compiledActionPath, compiledModules };
> };
>
> ```
>
> Add the test dependencies to `package.json`:
>
> ```json title="package.json" theme={null}
> {
>   "name": "actions-npm-example-ts-node-test",
>   "version": "1.0.0",
>   "description": "Auth0 Actions unit testing example using the Node.js built-in test runner and TypeScript",
>   "license": "MIT",
>   "author": "Auth0",
>   "scripts": {
>     "test": "node --test src/*.test.ts"
>   },
>   "devDependencies": {
>     "@auth0/actions": "^0.33.0",
>     "@types/node": "22.14.0",
>     "typescript": "^5.9.2"
>   }
> }
>
> ```
>
> Configure `tsconfig.json` so the `actions:` import alias resolves to `src/`:
>
> ```json title="tsconfig.json" theme={null}
> {
>   "compilerOptions": {
>     "target": "ES2020",
>     "module": "NodeNext",
>     "moduleResolution": "nodenext",
>     "esModuleInterop": true,
>     "allowSyntheticDefaultImports": true,
>     "strict": true,
>     "outDir": "dist",
>     "declaration": true,
>     "sourceMap": true,
>     "allowJs": true,
>     "checkJs": false,
>     "resolveJsonModule": true,
>     "skipLibCheck": true,
>     "forceConsistentCasingInFileNames": true,
>     "isolatedModules": true,
>     "noEmit": true,
>     "paths": {
>       "actions:*": [
>         "./src/*"
>       ]
>     }
>   },
>   "exclude": [
>     "node_modules",
>     "dist"
>   ],
>   "include": [
>     "**/*.ts"
>   ],
>   "ts-node": {
>     "transpileOnly": true
>   }
> }
>
> ```
