PIJET: Parallel, Isolated Jest-Enhanced Testing Part II: Implementation
Annotation
Dive into the practical side of PIJET in this informative piece. We move beyond the basics and delve into the actual implementation of this innovative testing approach. Learn the ins and outs of setting up PIJET, fine-tuning your tools, and ensuring Jest, TypeORM, and TypeScript work seamlessly together for more efficient testing.
This section of the article is hands-on, guiding you through real-world testing scenarios. You’ll discover how to write parallel tests that run smoothly without interference, gaining valuable tips and tricks from professionals in the field. We’re not just talking theory; we’re putting PIJET into action.
Filled with practical examples and real-life case studies, this part of the article showcases PIJET’s transformative impact on software testing. We wrap up with actionable insights, suggestions for enhancing your use of PIJET, and helpful advice for software developers, test engineers, and anyone involved in ensuring peak performance in software systems. Get ready to elevate your testing skills with the dynamic and powerful capabilities of PIJET.
Setting Up the Environment
Installing Necessary Tools
To effectively implement the PIJET methodology, certain tools are essential. These tools work in tandem to create a robust testing environment, ensuring that tests are executed efficiently and reliably. Here’s a guide on installing the necessary tools:
npm install typeorm --save
TypeORM
TypeORM is an ORM (Object-Relational Mapper) that makes working with databases more efficient and scalable. It’s crucial for managing database transactions and ensuring data isolation in tests.
To install TypeORM, use the following command:
npm install typeorm --save
Jest
Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It’s used for running tests and managing parallel test execution.
Install Jest using the command:
npm install jest --save-dev
ts-jest
ts-jest is a TypeScript preprocessor for Jest. It allows you to use Jest to test projects written in TypeScript.
To install ts-jest, run:
npm install ts-jest --save-dev
Supertest
Supertest is a Super-agent driven library for testing HTTP servers. It’s particularly useful for testing API endpoints.
Install Supertest with the following command:
npm install supertest --save-dev
After installing these tools, you’ll have a solid foundation to implement the PIJET methodology. Each tool plays a specific role in ensuring that your tests are not only efficient and parallel but also isolated and reliable.
Configuring Jest for Parallel Testing
Configuring Jest for parallel testing is a key step in implementing the PIJET methodology. Proper configuration ensures that tests are executed concurrently, significantly reducing the overall testing time. Here’s a guide to configuring Jest for parallel testing:
Create or Update Jest Configuration File
If you don’t already have a Jest configuration file (jest.config.js), create one in the root of your project.
If you already have a Jest configuration file, open it for editing and insert the following:
module.exports = {
"preset": "ts-jest",
"testEnvironment": "node"
,
"transform": {
"^.+\\.tsx?$": [
"ts-jest",
{
"tsconfig": "./tsconfig.tests.json"
}
]
},
"roots": [
"tests"
],
"testTimeout": 360000,
}
Enable Parallel Execution
Jest runs tests in parallel by default. However, you can control the degree of parallelism.
To limit the number of tests running in parallel, you can use the maxWorkers option. For example, to limit to 4 workers:
module.exports = {
// ... other configurations
maxWorkers: 4,
};
If you want Jest to use the optimal number of workers based on the number of CPU cores, you can omit the maxWorkers option or set it to “50%” to use half of the available cores.
Save the changes to your jest.config.js file.
Run your tests to ensure that the configuration is working as expected. You can use the command:
npx jest
Integrating TypeORM for Transactional Isolation
In the PIJET framework, integrating TypeORM for transactional isolation is a crucial step. By default, TypeORM does not support contextual transactions, requiring the explicit passing of a transaction object to each query call. To overcome this limitation and enhance the testing environment, a series of patches and extensions are implemented.
QueryRunnerPatcher.ts
import { QueryRunner } from 'typeorm';
interface ReleasePatchedQueryRunner extends QueryRunner {
releaseQueryRunner(): Promise<void>;
}
class QueryRunnerPatcher {
private static release: () => Promise<void>;
static wrap(originalQueryRunner: QueryRunner):
ReleasePatchedQueryRunner {
this.release = originalQueryRunner.release;
originalQueryRunner.release = () => {
return Promise.resolve();
};
(originalQueryRunner as ReleasePatchedQueryRunner).releaseQueryRunner = () => {
originalQueryRunner.release = this.release;
return originalQueryRunner.release();
};
return originalQueryRunner as ReleasePatchedQueryRunner;
}
}
export { QueryRunnerPatcher, ReleasePatchedQueryRunner };
Purpose: To modify the behavior of TypeORM’s QueryRunner for transactional testing.
Key Methods:
- wrap(originalQueryRunner: QueryRunner): This method takes the original QueryRunner and modifies its release method. It essentially prevents the release of the query runner until explicitly called, allowing for controlled transaction management.
- releaseQueryRunner(): This method restores the original release behavior of the QueryRunner, allowing it to be released when the transaction is completed.
TransactionalTestContext.ts
import { DataSource, QueryRunner, ReplicationMode } from 'typeorm';
import { ReleasePatchedQueryRunner, QueryRunnerPatcher } from './QueryRunnerPatcher';
class TransactionalTestContext {
private queryRunner: ReleasePatchedQueryRunner | null = null;
private originQueryRunnerFunction: (mode?: ReplicationMode | undefined) => QueryRunner;
constructor(private readonly connection: DataSource) {}
async start(): Promise<void> {
if (this.queryRunner) throw new Error('Context already started');
try {
this.queryRunner = this.buildWrappedQueryRunner();
this.monkeyPatchQueryRunnerCreation(this.queryRunner);
await this.queryRunner.connect();
await this.queryRunner.startTransaction();
} catch (error) {
await this.cleanUpResources();
throw error;
}
}
async finish(): Promise<void> {
if (!this.queryRunner) throw new Error('Context not started. You must call "start" before finishing it.');
try {
await this.queryRunner.rollbackTransaction();
this.restoreQueryRunnerCreation();
} finally {
await this.cleanUpResources();
}
}
private buildWrappedQueryRunner(): ReleasePatchedQueryRunner {
const queryRunner = this.connection.createQueryRunner();
return QueryRunnerPatcher.wrap(queryRunner);
}
private monkeyPatchQueryRunnerCreation(queryRunner: ReleasePatchedQueryRunner): void {
this.originQueryRunnerFunction = DataSource.prototype.createQueryRunner;
DataSource.prototype.createQueryRunner = () => queryRunner;
}
private restoreQueryRunnerCreation(): void {
DataSource.prototype.createQueryRunner = this.originQueryRunnerFunction;
}
private async cleanUpResources(): Promise<void> {
if (this.queryRunner) {
await this.queryRunner.releaseQueryRunner();
this.queryRunner = null;
}
}
}
export default TransactionalTestContext;
Purpose: To manage the transactional context for each test, ensuring isolation and data integrity.
Key Methods:
- start(): Initializes the transactional context by creating a wrapped QueryRunner and starting a transaction.
- finish(): Rolls back the transaction and cleans up resources, ensuring the database state is reset after each test.
- buildWrappedQueryRunner(): Creates a wrapped QueryRunner using the QueryRunnerPatcher.
- patchQueryRunnerCreation(): Temporarily overrides the createQueryRunner method of the DataSource to return the wrapped QueryRunner, ensuring all database operations within the test use the same transactional context.
- restoreQueryRunnerCreation(): Restores the original createQueryRunner method after the test is completed.
transaction.ts
import { QueryFailedError } from 'typeorm';
import AppTestProvider from '@tests/AppTestProvider';
function exponentialSleep(stage: number) {
const MAX_SLEEP = 60000;
const ms = (Math.floor(Math.random() * 100) * Math.pow(2, stage)) % MAX_SLEEP;
return new Promise((resolve) => setTimeout(resolve, ms));
}
function transaction(appTestProvider: AppTestProvider, testFn: () => Promise<unknown>, maxRetries = 12) {
return async () => {
for (let retries = 0; retries <= maxRetries; retries++) {
await appTestProvider.startTransaction();
try {
await testFn();
return appTestProvider.finishTransaction();
} catch (error) {
await appTestProvider.finishTransaction();
if (error instanceof QueryFailedError && error.message.includes('ER_LOCK_DEADLOCK')) {
await exponentialSleep(retries);
} else {
throw error;
}
}
}
throw new Error('Failed to execute test after maximum retries');
};
}
export default transaction;
Purpose: To provide a mechanism for executing tests within a transaction, with retry logic for handling database errors.
Key Methods:
- transaction(appTestProvider, testFn, maxRetries): Wraps the test function in a transaction. It handles starting and finishing the transaction, and includes retry logic for handling specific database errors like deadlocks.
inTransactionIt.ts
import AppTestProvider from '@tests/AppTestProvider';
import transaction from './transaction';
function inTransactionIt(
description: string,
appTestProvider: AppTestProvider,
testFunction: () => Promise<unknown>,
timeout?: number
): void {
const transactionalTest = transaction(appTestProvider, testFunction);
it(description, transactionalTest, timeout);
}
export default inTransactionIt;
Purpose: To extend Jest’s testing capabilities with a custom function for defining transactional tests.
Key Methods:
inTransactionIt(description, appTestProvider, testFunction, timeout): Defines a Jest test case that runs within a transaction. It uses the transaction function from transaction.ts to ensure the test is executed in an isolated transactional context.
Class: AppTestProvider
Purpose: To provide a specialized environment for running tests, including database setup, transaction management, and email mocking. It’s important to note that the AppProvider contains the logic for initializing the TypeORM data source, which can be accessed via the .dataSource property.
Key Components:
- Transactional Context: Manages the transactional context for tests, ensuring each test runs in an isolated transaction.
- Request Handling: Utilizes supertest for making HTTP requests to the Express application, facilitating API testing.
- Email Mocking: Integrates nodemailer-mock to mock email sending, allowing tests to verify email-related functionalities without sending real emails.
Key Methods
- constructor(): Initializes the test provider, setting up the request handler and other necessary components.
- start(): Prepares the testing environment by creating a test database, initializing the data source, and running migrations.
- shutdown(): Cleans up resources and shuts down the data source after tests are completed.
- startTransaction(): Initializes the transactional context, starting a new transaction for the upcoming test.
- finishTransaction(): Completes the transaction, either committing or rolling it back based on the test outcome.
- getDataSourceOptions(config): Configures data source options for the test environment, including setting up a separate database for each Jest worker.
- createWorkerTestDatabase(): Creates a separate test database for each Jest worker, ensuring isolation between parallel tests.
- getDatabaseName(): Generates a unique database name based on the Jest worker ID, used for creating isolated test databases.
import { Transport } from 'nodemailer';
import AppProvider from '@src/AppProvider';
import nodemailerMock from 'nodemailer-mock';
import stubTransport from 'nodemailer-stub-transport';
import TransactionalTestContext from './transactions/TransactionalTestContext';
import request from 'supertest';
import DomainModel from '@domain-model/DomainModel';
import IConfig from '@interfaces/IConfig';
class AppTestProvider extends AppProvider {
private transactionalContext?: TransactionalTestContext;
request: request.SuperTest<request.Test>;
constructor() {
super();
this.request = request(this.app.getExpressApp());
}
async start() {
await this.createWorkerTestDatabase();
await this.dataSource.initialize();
await this.dataSource.runMigrations();
}
async shutdown() {
await this.dataSource.destroy();
}
async startTransaction() {
this.transactionalContext = new TransactionalTestContext(this.dataSource);
await this.transactionalContext.start();
}
async finishTransaction() {
if (!this.transactionalContext) return;
await this.transactionalContext.finish();
}
protected getDataSourceOptions(config: IConfig) {
return {
...config['test-db'],
...(process.env.JEST_WORKER_ID && { database: this.getDatabaseName() }),
};
}
private async createWorkerTestDatabase() {
if (!process.env.JEST_WORKER_ID) return;
const dataSource = DomainModel.createDataSource(this.config['test-db']);
await dataSource.initialize();
await dataSource.query(`CREATE DATABASE IF NOT EXISTS \`${this.getDatabaseName()}\``);
await dataSource.destroy();
}
private getDatabaseName() {
return `${this.config['test-db'].database}_${process.env.JEST_WORKER_ID}`;
}
}
export default AppTestProvider;
Implementing PIJET in Practice
Writing Your First Parallel Test
The Create.spec.ts file under the api/admin/users directory serves as an excellent example for writing your first parallel test using the PIJET framework. This test file focuses on testing the user creation functionality in an admin context. Here’s an overview of its structure and key components:
Test Suite: [admin/users]: Create user
Purpose: To test the user creation functionality in the admin API.
Setup: Utilizes AppTestProvider for setting up the testing environment and UserStories for common user-related actions.
Key Components:
- AppTestProvider (app): An instance of AppTestProvider is created to manage the test environment, including database and transaction setup.
- UserStories (userStories): An instance of UserStories is used to perform common user-related actions, such as setting up authenticated users.
Test Lifecycle:
- beforeAll: Initializes the testing environment by starting the AppTestProvider.
- afterAll: Shuts down the testing environment, cleaning up resources.
Test Cases
- Creates a user:
- Description: Tests the successful creation of a user.
- Method: inTransactionIt
- Process:
- Sets up an authenticated user with specific permissions.
- Sends a POST request to create a new user.
- Verifies the response and the created user’s data.
- Fails if user is not logged in:
- Description: Tests the failure case when a user is not authenticated.
- Method: inTransactionIt
- Process:
- Sends a POST request without authentication.
- Verifies the response for the expected failure due to missing session.
- Fails if user is not permitted:
- Description: Tests the failure case when a user lacks necessary permissions.
- Method: inTransactionIt
- Process:
- Sets up an authenticated user without specific permissions.
- Sends a POST request to create a user.
- Verifies the response for the expected failure due to insufficient permissions.
Testing Approach
Parallel Execution: The test suite outlined in a single file will run synchronously. However, when multiple files are grouped together, they will run asynchronously across a pool of allocated processes..
Transactional Isolation: The use of inTransactionIt guarantees that each test case runs in its own transactional context, rolled back after completion, ensuring no interference between tests.
Code implementation:
import { UserStatusesEnum } from '@domain-model/entities/User';
import AppTestProvider from '@tests/AppTestProvider';
import UserStories from '@tests/stories/UserStories';
import inTransactionIt from '@tests/transactions/inTransactionIt';
describe('[admin/users]: Create user', () => {
const app = new AppTestProvider();
const userStories = new UserStories({ app });
beforeAll(async () => {
return app.start();
});
afterAll(async () => {
return app.shutdown();
});
inTransactionIt('Creates a users', app, async () => {
const { authCookies, role } = await userStories.setUpAuthenticatedUser({
role: { name: 'users-create', permissions: ['USERS_WRITE'] },
});
const newUserData = {
email: '[email protected]',
firstName: 'Test',
lastName: 'Test',
};
const response = await app.request
.post(`/admin-api/v1/users`)
.send({ ...newUserData, roleId: role.id })
.set('Accept', 'application/json')
.set('Cookie', authCookies)
expect(200);
expect(response.body.status).toEqual(1);
expect(response.body.data).toMatchObject(newUserData);
expect(response.body.data.status).toEqual(UserStatusesEnum.pending);
});
inTransactionIt('Fails if user is not logged in', app, async () => {
const response = await app.request
.post(`/admin-api/v1/users`)
.send({})
.set('Accept', 'application/json')
.expect(200);
expect(response.body.status).toEqual(0);
expect(response.body.error.code).toEqual('SESSION_REQUIRED');
});
inTransactionIt('Fails if users is not permitted', app, async () => {
const { authCookies } = await userStories.setUpAuthenticatedUser();
const response = await app.request
.post(`/admin-api/v1/users`)
.send({})
.set('Accept', 'application/json')
.set('Cookie', authCookies)
.expect(200);
expect(response.body.status).toEqual(0);
expect(response.body.error.code).toEqual('ACTION_FORBIDDEN');
});
});
Advanced Techniques and Best Practices
Two key concepts that significantly contribute to this are Factory Classes and Story Classes. Here’s an overview of each and how they can be effectively utilized:
Factory Classes
- Purpose: Factory classes are used to create and manage mock data for various entities in the testing environment. They help in generating consistent and controlled test data, which is essential for reliable testing.
- Benefits:
- Consistency: Ensures that test data across different test cases is consistent, reducing the likelihood of erratic test behavior.
- Reusability: Promotes code reusability by centralizing the creation of test data, making it easier to manage and modify.
- Isolation: Helps in maintaining test isolation by providing each test with its own set of data, preventing interference between tests.
Story Classes
- Purpose: Story classes contain sequences of actions that are frequently used across various test cases. They encapsulate common workflows or user stories, making them reusable across different tests.
- Benefits:
- Code Organization: Keeps the test code organized by abstracting common sequences into separate classes.
- Reduced Duplication: Minimizes code duplication by allowing multiple tests to reuse the same sequences of actions.
- Clarity: Enhances the readability and clarity of tests by abstracting complex sequences into well-named and self-explanatory story classes.
Implementing Factory and Story Classes
- Structure: Both factory and story classes should be organized in dedicated folders (factories and stories, respectively) to maintain a clear and organized codebase.
- Usage: In test cases, instead of manually setting up data or actions, utilize these classes to generate data or perform actions. This not only simplifies the test code but also ensures consistency and maintainability.
Best Practices
- Modular Design: Design factory and story classes in a modular way, allowing them to be easily extended or modified as the application evolves.
- Clear Naming Conventions: Use clear and descriptive names for both factory and story classes to indicate their purpose and usage.
- Documentation: Document the purpose and usage of each factory and story class, making it easier for other team members to understand and use them.
Analyzing Performance Improvements
The implementation of PIJET (Parallel, Isolated Jest-Enhanced Testing) has demonstrated significant performance improvements in test execution times. By comparing two scenarios — one using PIJET and the other using sequential tests — we can clearly see the impact of this approach.
Comparative Analysis
- Execution Time Reduction: The use of PIJET has reduced the test execution time from 100.689 seconds to 21.718 seconds. This represents a reduction of approximately 78.4% in execution time.
- Efficiency: The estimated time for completion also shows a significant decrease, from 230 seconds in sequential tests to 43 seconds with PIJET. This indicates a more efficient utilization of resources and faster feedback for developers.
- Reliability: Despite the drastic reduction in execution time, the reliability of the tests remains intact, as evidenced by the consistent number of passed test suites and tests in both scenarios.
Conclusions
Future Directions and Potential Enhancements
Focusing on this specific future direction, the development of open source libraries for integrating TypeORM and Jest with test-contextual transactions capabilities presents an exciting opportunity. Here’s a deeper look into this potential enhancement:
Seamless Integration:
The primary goal of these libraries would be to provide a seamless integration between TypeORM and Jest. This would enable developers to set up their testing environments with minimal configuration, reducing the initial setup complexity.
Test-Contextual Transactions:
A key feature of these libraries would be the ability to handle test-contextual transactions. Each test case would run within its own transaction, ensuring complete isolation. This means that any database changes made during a test would be rolled back at the end of the test, maintaining a clean state for subsequent tests.
Automated Transaction Management:
The libraries could automate the process of starting and rolling back transactions for each test. This would eliminate the need for manual transaction management in test setup and teardown, streamlining the testing process.
Easy Adoption and Community Support:
As open source projects, these libraries would be available for anyone to use, modify, and contribute to. This could foster a community of users and contributors who can provide support, share best practices, and contribute to the continuous improvement of the libraries.
Documentation and Examples:
Comprehensive documentation and real-world examples would be crucial for the adoption of these libraries. Clear instructions on how to integrate them into existing projects, along with sample code and use cases, would help developers understand and leverage their full potential.
Developing these open source libraries could significantly enhance the PIJET methodology, making it even more powerful and accessible for developers looking to optimize their testing processes.
Final Thoughts and Recommendations
As we conclude our exploration of the PIJET methodology, it’s clear that this approach stands as a beacon of efficiency and reliability in the complex world of software testing. The integration of Jest, TypeORM, and TypeScript under the PIJET umbrella has opened up new avenues for developers and testers alike, offering a streamlined path to achieving faster and more accurate test results. The potential of PIJET to transform testing workflows is not just theoretical; it’s a practical solution that addresses real-world challenges in software development.
Looking ahead, the future of PIJET is bright, especially with the prospect of developing open source libraries for TypeORM/Jest integration with test-contextual transactions capabilities. This enhancement alone could elevate PIJET to new heights, making it an even more indispensable tool in the developer’s arsenal. The collaborative and evolving nature of open source projects means that PIJET can continue to grow and adapt, staying relevant and effective in the face of changing technologies and practices.
Useful Links
- Jest: Delightful JavaScript Testing. [Online] Link: https://jestjs.io/
- Performing NodeJS Unit testing using Jest. [Online] Link: https://www.browserstack.com/guide/unit-testing-for-nodejs-using-jest
- Writing Unit Tests in Node.js Using Jest. [Online] Link: https://semaphoreci.com/blog/unit-tests-nodejs-jest
- Node.js Unit Testing with Jest. [Online] Link: https://medium.com/@ben.dev.io/node-js-unit-testing-with-jest-b7042d7c2ad0
- Node.js Express test-driven development with Jest. [Online] Link: https://blog.logrocket.com/node-js-express-test-driven-development-jest/m/how-to-speed-up-jest-test-runs-by-splitting-and-parallelising-them-1be7c1c8600d
- SuperTest. [Online] Link: https://www.npmjs.com/package/supertest
- What Should You Know About Supertest?. [Online] Link: https://www.accelq.com/blog/supertest/#:~:text=
Supertest%20is%20a%20Node.,tests%20for%20routes%20and%20endpoints. - SuperTest – A Detailed Guide To Efficient API Tests. [Online] Link: https://preflight.com/blog/supertest-a-detailed-guide/
- Deep Understanding of MySQL Transactions.. [Online] Link: https://itnext.io/deep-understanding-of-mysql-transactions-5d940f3c6e5f
- MySQL transactions. [Online] Link: https://www.scaler.com/topics/mysql-transactions/
- MySQL Transaction Tutorial With Programming Examples. [Online] Link: https://www.softwaretestinghelp.com/mysql-transaction-tutorial/
- START TRANSACTION, COMMIT, and ROLLBACK Statements. [Online] Link: https://dev.mysql.com/doc/refman/8.0/en/commit.html
- TypeORM – Amazing ORM for TypeScript and JavaScript. [Online] Link: https://typeorm.io/
- Handling Transactions in TypeORM and Nest.js With Ease. [Online] Link: https://betterprogramming.pub/handling-transactions-in-typeorm-and-nest-js-with-ease-3a417e6ab5
Learn more about how we engage and what our experts can do for your business
Let's ConnectWritten by:
Maksym Kotov
Node.js developer at Webbylab
Experienced Node.js developer passionate about crafting scalable backend solutions. Keen on JavaScript/TypeScript ecosystems, DevOps practices, and continuous learning.
Rate this article !
31 ratingsAvg 4.6 / 5
Let's innovate together! Leverage our expertise for your projects
Avg 4.3 / 5
Avg 4.6 / 5
Avg 4.4 / 5
Avg 4.6 / 5
Avg 4.6 / 5
Avg 4.4 / 5