Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mocking the Backend with node & Codi 🐶 #1884

Open
wants to merge 37 commits into
base: minor
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
aa17cc6
Update cloudfront provider test
RobAndrewHurst Feb 3, 2025
3abd0fb
Update cloudfront provider tests
RobAndrewHurst Feb 3, 2025
1595ecb
Update Cloudfront test messages
RobAndrewHurst Feb 3, 2025
e07c5a9
Update httpMocks
RobAndrewHurst Feb 5, 2025
4fbdb05
Update mocks
RobAndrewHurst Feb 6, 2025
4b1dba9
Update tests
RobAndrewHurst Feb 7, 2025
d00382d
User mod testing
RobAndrewHurst Feb 7, 2025
2a8b61f
getFrom provider tests
RobAndrewHurst Feb 7, 2025
1f80694
s3 provider tests
RobAndrewHurst Feb 7, 2025
8b5e00d
Update provider test structure
RobAndrewHurst Feb 7, 2025
85ec596
merge 'minor' into 'backend-tests'
RobAndrewHurst Feb 7, 2025
d4147b8
Update base sign tests
RobAndrewHurst Feb 8, 2025
b42774b
Update signer router tests
RobAndrewHurst Feb 8, 2025
b81faa1
Cloudfront signer test init
RobAndrewHurst Feb 8, 2025
83c0db7
Cloudfront signer test
RobAndrewHurst Feb 8, 2025
052ff68
Update cloudfront test
RobAndrewHurst Feb 8, 2025
a1d8b71
Cloudinary tests
RobAndrewHurst Feb 8, 2025
42309b8
Cloudinary sign tests
RobAndrewHurst Feb 8, 2025
1302287
s3 signer tests
RobAndrewHurst Feb 8, 2025
41d910f
Update workflow
RobAndrewHurst Feb 8, 2025
8136ff1
Update test scripts
RobAndrewHurst Feb 8, 2025
01e1bc4
admin user test
RobAndrewHurst Feb 10, 2025
3de4a83
user mod test update
RobAndrewHurst Feb 10, 2025
5c84606
user auth tests
RobAndrewHurst Feb 10, 2025
819b977
Update user auth test
RobAndrewHurst Feb 10, 2025
0643b47
user auth tests
RobAndrewHurst Feb 10, 2025
6734a9f
user cookie test
RobAndrewHurst Feb 10, 2025
56a64da
user delete tests
RobAndrewHurst Feb 10, 2025
d03f934
Update codi-test-framework
RobAndrewHurst Feb 11, 2025
3b28ded
Merge branch 'minor' into backend-tests
RobAndrewHurst Feb 11, 2025
b388986
update testing.md
RobAndrewHurst Feb 11, 2025
b331f1e
function and module mocking
RobAndrewHurst Feb 11, 2025
23b26e4
http mocks docs
RobAndrewHurst Feb 11, 2025
07fa443
http mocks Agent interception
RobAndrewHurst Feb 11, 2025
eceffb2
restore mocks documentation
RobAndrewHurst Feb 11, 2025
5750e79
Fix testing markdown
RobAndrewHurst Feb 11, 2025
85904e6
Merge branch 'minor' into backend-tests
RobAndrewHurst Feb 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,10 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '22'

- name: Install Dependencies
run: npm install

- name: Install Dependencies
run: npm install bun -g

- name: Run tests
run: bun run test
run: node --run test
279 changes: 256 additions & 23 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,32 +54,247 @@ export default {

## 1. CLI (Console) Tests

CLI tests are vanilla JavaScript tests that execute in the Node.js runtime using the Codi Test framework. These tests focus on the xyz (mod) directory and code that doesn't require browser-specific features.
CLI tests are javaScript tests that execute in the Node.js runtime using the Codi Test framework. These tests focus on the xyz (mod) directory and code that doesn't require browser-specific features.

The main testing pattern in the cli tests are test mocks.

### Mocks

> [!NOTE] Codi has implemented mock modules/functions that are only available in the nodeJS environment.

Module mocking is a strange and weird concept to get your head around. It is essentially is a way to replace modules or functions with stubs to simulate or 'mock' external entities.

What a mocking module will do is replace the reference of a module in memory with a 'mocked' version of it. This mocked version can then be called from non-test code and receive an output. You can then reset that version of the mocked module to the original export with a reset/restore function.

Codi can mock:

- functions
- modules
- http requests

#### function (fn) mocking

Just like any function, mocking a function needs to have context. The context can be the scope of a test, imported module or global.

To mock a function you can call the `codi.mock.fn()` function.
This creates a mock function which you can interface with.

```javascript
const random = codi.mock.fn((max) => return Math.floor(Math.random() * max););

codi.it({ name: 'random', parentId: 'foo' }, () => {
codi.assertTrue(random(1) === 0, 'We expect the number to be 0');
codi.assertTrue(random(3) <= 2), 'We expect the number to less than or equal to 2';
});
```

You can also mock functions/methods with the `codi.mock.method()` function that will take an object as a reference and implement a mock function to that objects method.

> [!NOTE]
> typically in tests written this methodology isn't used and favoured for the `codi.mock.mockImplementation()/mockImplementationOnce()` function which can mock a function given to a mocked module. An example of this will be provided in the mock module section.

```javascript
import fs from 'node:fs';

// Mocking fs.readFile() method
codi.mock.method(fs.promises, 'readFile', async () => 'Hello World');

codi.describe({ name: 'Mocking fs.readFile in Node.js', id: 'mock' }, () => {
codi.it(
{
name: 'should successfully read the content of a text file',
parentId: 'mock',
},
async () => {
codi.assertEqual(fs.promises.readFile.mock.calls.length, 0);
codi.assertEqual(
await fs.promises.readFile('text-content.txt'),
'Hello World',
);
codi.assertEqual(fs.promises.readFile.mock.calls.length, 1);

// Reset the globally tracked mocks.
mock.reset();
},
);
});
```

#### module mocking

You can mock modules with the `codi.mock.module()` function which takes a path and options to mock a module.
The typical practice is that you provide a mocked function that you can implement mocks ontop of making the mocked module more reusable.

> [!CAUTION]
> the `codi.mock.module()` function is still in early development as it comes from the node:test runner, which is still in further development

options you can provide mocked module:

- cache: If false, each call to require() or import() generates a new mock module. Default: false.
- defaultExport: An optional value used as the mocked module's default export. If this value is not provided, ESM mocks do not include a default export.
- namedExports: An optional object whose keys and values are used to create the named exports of the mock module.

Bellow is an example of a mocked module referencing a mocked function

> [!IMPORTANT]
> Ensure that your module mocks are top level, as to import the module before the dynamic import to the module we are testing.

```javascript
const aclFn = codi.mock.fn();
const mockedacl = codi.mock.module('../../acl.js', {
cache: false,
defaultExport: aclFn
namedExports:{
acl: aclFn
}
});

codi.describe({name: 'mocked module', id: 'mocked_module'}, () => {
codi.it({'We should be able to mock a module', parentId: 'mocked_module'}, async () => {

aclFn.mock.mockImplementation(() => {
const user = {
email: '[email protected]',
admin: true
};
return user
})

const { default: login } = await import('../../../mod/user/login.js');

const result = await loing();

//{ email: '[email protected]', admin: true}
console.log(result);
});
});
```

#### module & function restore/reset

if you want to return the functionality of a mocked function/module you will want to call the `restore` function on a mocked module.

> [!IMPORTANT]
> You will want to call these restores on mocked modules at the end of a test so that other tests can also mock the same module. If you don't an error will be returned.

```javascript
const aclFn = codi.mock.fn();
const mockedacl = codi.mock.module('../../acl.js', {
cache: false,
defaultExport: aclFn
namedExports:{
acl: aclFn
}
});

codi.describe({name: 'mocked module', id: 'mocked_module'}, () => {
codi.it({'We should be able to mock a module', parentId: 'mocked_module'}, async () => {
//...test
});
});

//Call to the mocked module to restore to original state.
mockedacl.restore();
```

#### http mocks

codi has exported functions to help aid in mocking http requests.

`codi.mockHttp` helps create `req` & `res` objects that can be passed to functions in order to simulate a call to the function via an api. You can call the `createRequest` & `createResponse` functions respectively. You can also call the `createMocks` function and perform a destructured assignment on the `req` & `res`.

```javascript
await codi.describe({ name: 'Sign: ', id: 'sign' }, async () => {
await codi.it({ name: 'Invalid signer', parentId: 'sign' }, async () => {
const { default: signer } = await import('../../../mod/sign/_sign.js');

const req = codi.mockHttp.createRequest({
params: {
signer: 'foo',
},
});

const res = codi.mockHttp.createResponse();

//OR

const { req, res } = codi.mockHttp.createMocks({
params: {
signer: 'foo',
},
});

await signer(req, res);

codi.assertEqual(res.statusCode, 404);
codi.assertEqual(res._getData(), "Failed to validate 'signer=foo' param.");
});
});
```

You can also mock the response from the global fetch function by making use of the `MockAgent` & `setGlobalDispatcher` interfaces.

The `MockAgent` class is used to create a mockpool which can intercept different paths to certain URLs. And based on these paths we can specify a return.

The `setGlobalDispatcher` will assign the Agent on a global scope so that calls to the `fetch` function in non-test modules will be intercepted.

Here is an example of this:

```javascript
await codi.describe({ name: 'HTTP Mock', id: 'http_test_fun' }, async () => {
await codi.it(
{ name: 'We should get some doggies', parentId: 'http_test_fun' },
async () => {
const mockAgent = new codi.mockHttp.MockAgent(); //<-- Mockagent we use to get a pool
codi.mockHttp.setGlobalDispatcher(mockAgent); // <-- Assigning the agent on a global scope.

const mockPool = mockAgent.get(new RegExp('http://localhost:3000')); //<-- Mock pool listening for the localhost url
mockPool
.intercept({ path: '/' })
.reply(404, [
'codi',
'mieka',
'luci',
]); /** <-- When we hit a specific path
we get a specified response */

const response = await fetch('http://localhost:3000');

codi.assertEqual(response.status, 404, 'We expect to get a 404');
codi.assertEqual(await response.json(), ['codi', 'mieka', 'luci']);
},
);
});
```

### Running CLI Tests

The codi test suit will iterate through the tests directory [ignoring the folders specified in codi.json] and log the results from each test suit.

```bash
npm run test
node --run test
```

Summary statistics for all tests will be logged with the `-- quiet` flag (codi v0.0.47+):
You can also call the tests in watch mode where changes to the xyz codebase will retrigger the tests on save.
Running the watched tests will only show the tests that fail.

```bash
npm run test -- --quiet
node --run test-watch
```

## 2. Module (Browser) Tests
> [!NOTE] It's better to call the scripts in the package.json with the `node --run` command as it's faster than npm.
> This is part of node v22+

Module tests are designed for the browser environment with full access to:
## 2. Browser Tests

Browser tests are designed for the browser environment with full access to:

- DOM
- Mapp library
- Mapview for loaded application
- No mocking required for module imports

### Running Module Tests
### Running Browser Tests

A [test application view](https://github.com/GEOLYTIX/xyz/blob/main/public/views/_test.html) is provided in the public folder to execute browser tests.

Expand Down Expand Up @@ -110,28 +325,46 @@ Tests use the describe-it pattern for organization:
```javascript
import { describe, it, assertTrue } from 'codi';

describe('Feature Description', () => {
it('should behave in a specific way', () => {
// Test code
});
describe({ name: 'Feature Description', id: 'feature_description' }, () => {
it(
{
name: 'should behave in a specific way',
parentId: 'feature_description',
},
() => {
// Test code
},
);
});
```

Example with multiple assertions:

```javascript
describe('All languages should have the same base language entries', () => {
Object.keys(mapp.dictionaries).forEach((language) => {
it(`The ${language} dictionary should have all the base keys`, () => {
Object.keys(base_dictionary).forEach((key) => {
assertTrue(
!!mapp.dictionaries[language][key],
`${language} should have ${key}`,
);
});
codi.describe(
{
name: 'All languages should have the same base language entries',
id: 'dictionaries',
},
() => {
Object.keys(mapp.dictionaries).forEach((language) => {
codi.it(
{
name: `The ${language} dictionary should have all the base keys`,
parentId: 'dictionaries',
},
() => {
Object.keys(base_dictionary).forEach((key) => {
codi.assertTrue(
!!mapp.dictionaries[language][key],
`${language} should have ${key}`,
);
});
},
);
});
});
});
},
);
```

### Available Assertions
Expand Down Expand Up @@ -226,7 +459,7 @@ try {
NODE_ENV=DEVELOPMENT
```

> This can be defined in your .env or in your nodemon.json config.
> This can be defined in your .env or in your nodemon.json config.

2. Build the project:

Expand Down
1 change: 1 addition & 0 deletions mod/sign/_sign.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export default async function signer(req, res) {
.send(`Failed to validate 'signer=${req.params.signer}' param.`);
}

//TODO : Discuss if this check is needed
if (signerModules[req.params.signer] === null) {
return res
.status(405)
Expand Down
1 change: 1 addition & 0 deletions mod/sign/cloudinary.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Exports the cloudinary signer method.
@requires module:/utils/processEnv
*/

//TODO: I think we should think about removing these crypto imports as they are global
import { createHash } from 'crypto';

/**
Expand Down
3 changes: 1 addition & 2 deletions mod/sign/s3.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@ let clientSDK, getSignedUrl, credentials;
// Check if optional dependencies are available
try {
clientSDK = await import('@aws-sdk/client-s3');
const presigner = await import('@aws-sdk/s3-request-presigner');
getSignedUrl = presigner.getSignedUrl;
({ getSignedUrl } = await import('@aws-sdk/s3-request-presigner'));
} catch {
// Dependencies not installed
}
Expand Down
2 changes: 1 addition & 1 deletion mod/user/cookie.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ export default async function cookie(req, res) {
// Verify current cookie
jwt.verify(cookie, xyzEnv.SECRET, async (err, payload) => {
if (err) return err;

// Get updated user credentials from ACL
const rows = await acl(
`
Expand Down Expand Up @@ -104,6 +103,7 @@ export default async function cookie(req, res) {
return res.status(403).send('Account is blocked');
}

//TODO: Is this needed if we return an error?
delete user.blocked;

if (payload.session) {
Expand Down
Loading