492 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			492 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
---
 | 
						||
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
 | 
						||
- It’s 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'
 | 
						||
}
 | 
						||
```
 |