eolas/neuron/eae3c214-e7de-4efe-ad0c-420770267aa7/Testing_with_Jest.md

493 lines
12 KiB
Markdown
Raw Normal View History

2024-12-09 18:34:15 +00:00
---
tags: [javascript, testing, jest]
---
# Testing with Jest
## Mocking classes/modules
### Classes
Let' say we have this class:
```js
// database.js
class Database {
connect() {}
save(data) {}
}
```
Then to mock:
```js
import Database from "./database";
// This will mock the whole Database class, replacing all methods with jest mock functions.
jest.mock("./database");
test("should use mocked save method", () => {
const dbInstance = new Database();
// Mocking the save method with a specific return value
dbInstance.save.mockReturnValue(true);
const result = dbInstance.save({ key: "value" });
expect(result).toBe(true);
expect(dbInstance.save).toHaveBeenCalledWith({ key: "value" });
// The connect method is still a mock function (but without a specific behavior).
dbInstance.connect();
expect(dbInstance.connect).toHaveBeenCalled();
});
```
### Modules
Say we have the following module file:
```js
// utils.js
export const doSomething = () => {
// ...
};
export const fetchUserData = async (userId) => {
const response = await axios.get(`/api/users/${userId}`);
return response.data;
};
```
Mocked:
```js
jest.mock("./utils", () => {
return {
doSomething: jest.fn(() => "mocked doSomething"),
fetchUserData: jest.fn((userId) =>
Promise.resolve({ id: userId, name: "Mock User" })
),
};
});
test("should use mocked module functions", () => {
expect(utils.doSomething()).toBe("mocked doSomething");
expect(utils.doSomething).toHaveBeenCalled();
const result = await utils.fetchUserData(123);
expect(result).toEqual({ id: 123, name: "Mock User" });
expect(utils.fetchUserData).toHaveBeenCalledWith(123);
});
```
### Inline mocking versus "per test" mocking
There are two different architectures that we can use when mocking modules and
classes: **inline** and **per test** mocking.
Here is the inline case:
```js
jest.mock("./some_module.js", () => {
return {
someFunction: jest.fn(() => "value"),
someFunctionWithParam: jest.fn((param) => ({
property: param,
})),
someAsyncFunction: jest.fn(() => Promise.resolve("value")),
};
});
```
Here is the per test case:
```js
import { someModule } from "./some_module.js";
let someModuleMock;
someModuleMock = {
someFunction: jest.fn(() => "value"),
someFunctionWithParam: jest.fn((param) => ({
property: param,
})),
someAsyncFunction: jest.fn(() => Promise.resolve("value")),
};
someModule.mockImplementation(() => someModuleMock);
it("should do something", () => {
const newValue = "new value";
someModule.someFunction.mockReturnValue(newValue);
});
```
The benefits of inline:
- Inline is good because everything is set up in one place
- Inline keeps consistency accross tests: every test case in the file will use
the same mocked function unless overwritten within a test
- It lends itself to being a _global_ mock that can be used accross test files
in a `__mocks__/` directory
The benefits of per-test:
- You can very mock implementations within the file, providing more granular
control. You can redefine `someModuleMock` or parts of it
(`someModule.someFunction`) throughout your test file to accomodate varied
requirements between tests
- Its beneficial when your tests have divergent requirements, as you can
perform more detailed setups and overrides for each individual test case or
suite, ensuring mocks are configured exactly as required.
#### Overriding inline mocks
Per test mocking makes it straightforward to change the test parameters of the
mocked module or class but you can also override inline mocks.
If we were using the `someModule` inline mock and we wanted to override the
`someFunction` function that we have defined inline, we would first import the
`someFunction` function and then use `mockImplementation` against it:
```js
import { someFunction } from "./some_module.js";
someFunction.mockImplementation(() => "custom value");
expect(someFunction()).toBe("custom value");
// Optional: Restore the original mock implementation after the test
someFunction.mockRestore();
```
Note: although we are importing `someFunction` we are not actually importing the
real function tha belongs to the module. Because Jest mocks all of its
properties and methods with the inline syntax, we are actually just importing
that which Jest has aready mocked, but the syntax is a bit misleading.
#### Applied to classes
The same approaches (with minor differences) can be used with classes:
Using inline (where the class is not the default export):
```js
jest.mock("./SomeClass", () => {
return {
SomeClass: jest.fn().mockImplementation(() => {
return {
someFunction: jest.fn(() => "value"),
someFunctionWithParam: jest.fn((param) => ({ property: param })),
someAsyncFunction: jest.fn(() => Promise.resolve("value")),
someOtherFunctionThatResolves: jest.fn().mockResolvedValue("some data"),
};
}),
};
});
```
Using per test:
```js
import SomeClass from "./someClass";
jest.mock("./someClass");
let someClassMock = {
someFunction: jest.fn(() => "value"),
someFunctionWithParam: jest.fn((param) => ({ property: param })),
someAsyncFunction: jest.fn(() => Promise.resolve("value")),
};
// Mock class implementation
SomeClass.mockImplementation(() => someClassMock);
it("should do something", () => {
const newValue = "new value";
someClassMock.someFunction.mockReturnValue(newValue);
});
```
## Check that a function has been called within another function
```js
function toBeCalledFunction() {
console.log("Original function called");
}
function callerFunction() {
toBeCalledFunction();
}
```
```js
test("spy on toBeCalledFunction", () => {
const spy = jest.spyOn(global, "toBeCalledFunction"); // Replace `global` with the appropriate object/context if the function is not global
callerFunction();
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // Restore the original function after spying
});
```
## Mock a function that needs to resolve to something within another function
We have two functions, one that gets data and another that processes it. We want
to mock the function that gets data and return a value that the processing
function can use.
```js
async function getData() {
// ... Fetch some data from an API or database
return fetchedData;
}
async function processData() {
const data = await getData();
// ... Process the data
return processedData;
}
```
The mocking part:
```js
const mockData = { key: "value" }; // Mocked data
jest.mock("./path-to-file-where-getData-is", () => ({
getData: jest.fn().mockResolvedValue(mockData),
}));
test("test processData function", async () => {
const result = await processData();
// Now, result contains the processed version of mockData
expect(result).toEqual(/* expected processed data based on mockData */);
});
```
We could also combine the above with a spy to check that the `getData` function
was called:
```js
const getDataSpy = jest
.spyOn(moduleContainingGetData, "getData")
.mockResolvedValue(mockData);
const result = await processData();
expect(getDataSpy).toHaveBeenCalled();
expect(result).toEqual(/* expected processed data based on mockData */);
getDataSpy.mockRestore();
```
## Mock a function that takes arguments
```js
function addPrefix(str) {
return `prefix-${str}`;
}
```
```js
test("dynamic mock for addPrefix function", () => {
const mockFunction = jest.fn((str) => `mock-${str}`);
// Example usage of mockFunction
const result1 = mockFunction("test");
const result2 = mockFunction("example");
expect(result1).toBe("mock-test");
expect(result2).toBe("mock-example");
});
```
## Mocking network requests
### Mocking Axios
```js
jest.mock("axios", () => ({
get: jest.fn().mockResolvedValue(mockData),
post: jest.fn().mockResolvedValue(mockData),
}));
```
Or we could implement this way:
```js
jest.mock("axios");
axios.get.mockResolvedValue({ data: "mockedData" });
axios.post.mockResolvedValue({ data: "mockedData" });
```
Then we can use the mocked axios functions in our tests:
```js
const result = await fetchData(); // the function that uses Axios `get``
expect(result).toBe("mockedGetData");
const result = await sendData({ key: "value" }); // the function tha uses Axios `post`
expect(result).toBe("mockedPostData");
```
### `mockImplementation`
For more configurable cases we can use `mockImplementation`:
```js
it("sends data", async () => {
// Mock axios.post using mockImplementation
axios.post.mockImplementation((url, data) => {
if (data.key === "value") {
return Promise.resolve({ data: "mockedPostData" });
} else {
return Promise.reject({ error: "An error occurred" });
}
});
const result = await sendData({ key: "value" });
expect(result).toBe("mockedPostData");
});
```
If we want to change the `get` and `post` values in different tests, we can do
so by using `mockImplementation`:
## Mocking exceptions
Again we use `mockImplementation`:
Say we have the following function:
```js
// fetchData.js
import axios from "axios";
const fetchData = async (url) => {
try {
const response = await axios.get(url);
return response.data;
} catch (error) {
throw new Error("Error fetching data");
}
};
export default fetchData;
```
We would mock the success and the error as follows:
```js
import axios from "axios";
import fetchData from "./fetchData";
jest.mock("axios");
describe("fetchData", () => {
it("fetches data successfully", async () => {
axios.get.mockResolvedValue({ data: "mockedData" });
const result = await fetchData("https://api.example.com/data");
expect(result).toBe("mockedData");
});
it("throws an error when fetching fails", async () => {
axios.get.mockImplementation(() => {
throw new Error("API error");
});
// We use an asynchronous assertion here because we're expecting a promise to reject
await expect(fetchData("https://api.example.com/data")).rejects.toThrow(
"Error fetching data"
);
});
});
```
## Parameterization
The following offers a good opportunity for parameterisation:
```js
it("should return page for deletion from `ipages-live`", async () => {
// preview = false, isInternal = false
await deletePageFromS3("url", false, false);
const deleteObjectCommand = s3ClientMock.calls()[0].args[0];
expect(deleteObjectCommand.input).toEqual({
Bucket: "bbc-ise-ipages-live",
Key: "url/index.html",
});
});
it("should return page for deletion from `preview`", async () => {
// preview = true, isInternal = false
await deletePageFromS3("url", true, false);
const deleteObjectCommand = s3ClientMock.calls()[0].args[0];
expect(deleteObjectCommand.input).toEqual({
Bucket: "staff.bbc.com-preview",
Key: "preview/url/index.html",
});
});
...
```
Each time we are passing in three parameters to the `deletePageFromS3` function
which is the object under test. Each time there are different variations in the
object that is output.
To parameterize the process rather than use repeated `it` blocks we can combine
the input paramters and outputs into an array:
```js
const testParams = [
{
preview: false,
isInternal: false,
bucket: "ipages-live",
key: "url/index.html",
},
{
preview: true,
isInternal: false,
bucket: "staff.com-preview",
key: "preview/url/index.html",
},
];
```
Then use `it.each` to loop through all possible parameter combinations:
```js
it.each(testParams)(
"should return page for deletion from %s",
async ({ preview, isInternal, bucket, key }) => {
await deletePageFromS3("url", preview, isInternal);
const deleteObjectCommand = s3ClientMock.calls()[0].args[0];
expect(deleteObjectCommand.input).toEqual({
Bucket: bucket,
Key: key,
});
}
);
```
This uses the `%s` variable to print the parameters from each test, which
outputs:
```
✓ should return page for deletion from {
preview: false,
isInternal: false,
bucket: 'ipages-live',
key: 'url/index.html'
} (1 ms)
✓ should return page for deletion from {
preview: true,
isInternal: false,
bucket: 'staff.com-preview',
key: 'preview/url/index.html'
}
```