Testing
tramvai
provides a complete set of utilites for unit and integration testing.
This utilities based on main tramvai
concepts and features, and built on top of solid testing solutions (any of this dependencies are optional):
Setup environment
This setup will use jest
as test runner.
⌛ Install jest
dependencies and ts-node
for TS config support:
- npm
- Yarn
npm install --save-dev jest jest-circus jest-environment-jsdom @types/jest ts-node
yarn add --dev jest jest-circus jest-environment-jsdom @types/jest ts-node
⌛ Install tramvai-specific jest presets (tramvai add
command considers current tramvai
version in application):
npx tramvai add --dev @tramvai/test-unit-jest
npx tramvai add --dev @tramvai/test-integration-jest
⌛ Create jest.config.ts
as preset for unit tests:
import type { Config } from '@jest/types';
const config: Config.InitialOptions = {
preset: '@tramvai/test-unit-jest',
testPathIgnorePatterns: ['node_modules/', '__integration__'],
};
export default config;
⌛ And jest.integration.config.ts
for integration tests:
import type { Config } from '@jest/types';
const config: Config.InitialOptions = {
preset: '@tramvai/test-integration-jest',
testMatch: ['**/__integration__/**/?(*.)+(test).[jt]s?(x)'],
};
export default config;
⌛ At last, update scripts
in package.json
:
{
"name": "test-app",
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:integration": "jest -w=3 --config ./jest.integration.config.js",
"test:integration:watch": "jest --runInBand --watch --config ./jest.integration.config.js"
}
}
Unit tests
The basic building blocks of any tramvai
application are components, DI providers, reducers and actions.
Unit testing approach is perfectly suited for these blocks.
Actions
Actions can contain complex logic and interactions with global state and DI providers that can easily be covered by unit tests. Library @tramvai/test-unit
export testAction
API for this purpose, here a simple example:
import { declareAction } from '@tramvai/core';
import { testAction } from '@tramvai/test-unit';
// simple action, just return some value
export const action = declareAction({
name: 'test',
fn() {
return 'ok';
},
});
it('test', async () => {
const { run } = testAction(action);
// run our action and read result
const result = await run();
expect(result).toEqual('ok');
});
Even for simple unit tests some of important dependencies need to be mocked or initialized:
- DI container
- Store for reducers
testAction
provides a default setup for these dependencies and parameters for customizing them.
Dependency mocks
@tramvai/test-mocks
library provides a set of mocks for the popular tramvai
dependencies, including:
ENV_MANAGER_TOKEN
LOGGER_TOKEN
APP_INFO_TOKEN
REQUEST_MANAGER_TOKEN
COOKIE_MANAGER_TOKEN
CREATE_CACHE_TOKEN
CONTEXT_TOKEN
For complex unit tests, you can prevent writing a boilerplate code with CommonTestModule
, which initializes all mocks and provides them to the DI container:
import { testAction } from '@tramvai/test-unit';
import { CommonTestModule } from '@tramvai/test-mocks';
const action = declareAction({
name: 'test',
async fn() {
const { logger, envManager } = this.deps;
const value = envManager.get('ENV_KEY');
logger.log(value);
},
// CommonTestModule provides mocks for this deps
deps: {
logger: LOGGER_TOKEN,
envManager: ENV_MANAGER_TOKEN,
},
});
it('test', async () => {
const { run } = testAction(action, {
modules: [
// configure `ENV_MANAGER_TOKEN` mock, created by `CommonTestModule`, with `forRoot`
CommonTestModule.forRoot({ env: { ENV_KEY: 'ENV_VALUE' } }),
],
});
await run();
});
DI configuration
DI mock is the main opportunity to change the behavior of dependencies in unit tests. You can pass a custom DI container to utilities, or only list of required modules and providers. In example below both test suites are equivalent:
import { testAction } from '@tramvai/test-unit';
import { createMockDi } from '@tramvai/test-mocks';
it('pass custom di', async () => {
const di = createMockDi({ modules: [], providers: [] });
const { run } = testAction(action, { di });
await run();
});
it('pass modules and providers', async () => {
const { run } = testAction(action, { modules: [], providers: [] });
await run();
});
For example, you want to mock HTTP client for action, so you need to provide HTTP_CLIENT
mock:
import { testAction } from '@tramvai/test-unit';
import { declareAction } from '@tramvai/core';
import { HTTP_CLIENT } from '@tramvai/tokens-http-client';
// simple action with request to /foo/bar endpoint
const action = declareAction({
name: 'test',
async fn() {
const { httpClient } = this.deps;
await httpClient.request('/foo/bar');
},
deps: {
// default HTTP client from @tramvai/module-http-client
httpClient: HTTP_CLIENT,
},
});
it('test', async () => {
const request = jest.fn(async () => ({ payload: null }));
// create mock for HTTP_CLIENT
const providers = [
{
provide: HTTP_CLIENT,
useValue: {
request,
},
},
];
// pass this mock to DI
const { run } = testAction(action, { providers });
await run();
// check than request was called
expect(request).toHaveBeenCalledWith('/foo/bar');
});
Better way to test HTTP calls is to mock as little as possible. For unit tests, prefer to mock node-fetch
library, and use real HTTP clients implementations.
This kind of mocks requires more setup code, because we need to initialize tramvai
network layer. Some modules export specific utilities for simplifying testing process. For example, @tramvai/module-http-client
export testApi helper.
Store configuration
For tramvai
Store mock you can pass a list of reducers, or initial state object, or both. Reducers will be registered in Store directly, and if initial state will have the same keys, this values will be applied for the relevant reducers. For the rest of initial state keys, fake reducers will be created.
Example with passed stores
:
import { createReducer } from '@tramvai/state';
import { createMockStore } from '@tramvai/test-mocks';
const reducer = createReducer('test', { value: 'initial' });
const store = createMockStore({ stores: [reducer] });
const state = store.getState();
console.log(state.test); // { value: 'initial' }
Example with passed initialState
:
import { createMockStore } from '@tramvai/test-mocks';
const initialState = { test: { value: 'initial' } };
const store = createMockStore({ initialState });
const state = store.getState();
console.log(state.test); // { value: 'initial' }
Example with store and replaced initial state:
import { createReducer } from '@tramvai/state';
import { declareAction } from '@tramvai/core';
import { testAction } from '@tramvai/test-unit';
const reducer = createReducer('test', { value: 'initial' });
// the same key as the reducer name
const initialState = { test: { value: 'replaced' } };
const store = createMockStore({ stores: [reducer], initialState });
const state = store.getState();
console.log(state.test); // { value: 'replaced' }
And complete example with action testing:
import { createReducer, createEvent } from '@tramvai/state';
import { declareAction } from '@tramvai/core';
const increment = createEvent('increment');
const reducer = createReducer('counter', 0).on(increment, (state) => state + 1);
const action = declareAction({
name: 'increment',
async fn() {
await this.dispatch(increment());
},
});
it('test', async () => {
// no need to use createMockStore directly, just pass store to testAction
const { run, context } = testAction(action, { stores: [reducer] });
// context.getState is just alias for store.getState
expect(context.getState()).toEqual({ counter: 0 });
await run();
expect(context.getState()).toEqual({ counter: 1 });
});
Reducers
Every reducers in tramvai
application is an independent part of the global Store, and also can be covered by unit tests. Because reducers do not interact directly with DI, we don't need to mock anything. Library @tramvai/test-unit
export testReducer
API for this purpose, where Store will be created automatically, e.g.:
import { createReducer, createEvent } from '@tramvai/state';
import { testReducer } from '@tramvai/test-unit';
const increment = createEvent('increment');
const reducer = createReducer('counter', 0).on(increment, (state) => state + 1);
it('increment', () => {
const store = testReducer(reducer);
expect(store.getState()).toEqual(0);
store.dispatch(increment());
expect(store.getState()).toEqual(1);
});
Modules
In general, modules in tramvai
are just configurable sets of DI providers, united by common features. Testing a module involves checking its initialization and behavior of the providers added to the application. Library @tramvai/test-unit
exports testModule
API for this purpose, and like testAction
API, DI container mock will be created automatically, and can be configured or replaced.
Integration tests usually is a better way to testing complex modules, because you can test a real application behavior, not only tramvai
internals.
If the module being tested has a direct impact on the response of the application (HTML markup, redirects, any other side-effect), use @tramvai/test-integration
library, and additionally @tramvai/test-pw
to run tests in a real browser. But if your module only adds some dependencies without side-effects to DI, e.g. API clients, unit testing is a simpliest way to go.
Imagine, you write a tramvai
module, which provide custom logger for your application:
import { Module, provide, createToken } from '@tramvai/core';
interface CustomLogger {
log(message: string): void;
}
const CUSTOM_LOGGER_TOKEN = createToken<(name: string) => CustomLogger>('custom logger');
@Module({
providers: [
provide({
provide: CUSTOM_LOGGER_TOKEN,
useValue: (name: string) => ({
log: (message: string) => console.log(`[${name}] ${message}`),
}),
}),
],
})
class CustomLoggerModule {}
Simple unit test for CUSTOM_LOGGER_TOKEN
provider can look like this:
import { testModule } from '@tramvai/test-unit';
jest.spyOn(global.console, 'log');
it('test', () => {
const { di } = testModule(CustomLoggerModule);
const logger = di.get(CUSTOM_LOGGER_TOKEN);
logger('test').log('hello world');
expect(console.log).toBeCalledWith('[test] hello world');
});
Command line runner
For example, we want to add some logic to commandLineRunner
step, let's create a new module:
import { Module, provide, commandLineListTokens } from '@tramvai/core';
@Module({
providers: [
provide({
provide: commandLineListTokens.customerStart,
useValue: () => {
console.log('customer_start line');
},
}),
],
})
class TestModule {}
testModule
will return runLine
method for easy calls of different stages:
import { testModule } from '@tramvai/test-unit';
import { commandLineListTokens } from '@tramvai/core';
jest.spyOn(global.console, 'log');
it('test', async () => {
const { runLine } = testModule(TestModule);
expect(console.log).not.toBeCalled();
// Run only specific command line in order to execute handlers for this line inside module
await runLine(commandLineListTokens.customerStart);
expect(console.log).toBeCalledWith('customer_start line');
});
Conjunction with other modules
For example, we want to use logger from CustomLoggerModule
in our TestModule
:
import { Module, provide, commandLineListTokens } from '@tramvai/core';
@Module({
providers: [
provide({
provide: commandLineListTokens.customerStart,
useFactory: ({ loggerFactory }) => {
const logger = loggerFactory('test');
return () => {
logger.log('customer_start line');
};
},
deps: {
loggerFactory: CUSTOM_LOGGER_TOKEN,
},
}),
],
})
class TestModule {}
We can pass CustomLoggerModule
to modules
property in testModule
utility:
import { testModule } from '@tramvai/test-unit';
import { commandLineListTokens } from '@tramvai/core';
jest.spyOn(global.console, 'log');
it('test', async () => {
const { runLine } = testModule(TestModule, {
modules: [CustomLoggerModule],
});
expect(console.log).not.toBeCalled();
await runLine(commandLineListTokens.customerStart);
expect(console.log).toBeCalledWith('[test] customer_start line');
});
Components
testComponent
uses @testing-library/react under the hood, initializes tramvai
mocks and wraps target component in necessary providers.
Only React >= 18 version is supported
Use jest jsdom
environment for components unit testing.
Basic unit test example:
/**
* @jest-environment jsdom
*/
import { testComponent, screen } from '@tramvai/test-react';
const Component = ({ id }: { id: string }) => {
return <div data-testid={id}>Content</div>;
};
it('event', () => {
// render
testComponent(<Component id="test" />);
// assert
expect(screen.getByTestId('test').textContent).toBe('Content');
});
Props changing
/**
* @jest-environment jsdom
*/
import { testComponent, screen } from '@tramvai/test-react';
const Component = ({ content }: { content: string }) => {
return <div data-testid="test">{content}</div>;
};
it('props', () => {
const { rerender } = testComponent(<Component content="Content" />);
expect(screen.getByTestId('test').textContent).toBe('Content');
rerender(<Component content="New content" />);
expect(screen.getByTestId('test').textContent).toBe('New content');
});
User interactions
testComponent
will return fireEvent method for events simulation:
/**
* @jest-environment jsdom
*/
import { useState } from 'react';
import { testComponent } from '@tramvai/test-react';
const Component = () => {
const [count, setCount] = useState(0);
return (
<>
<h3>{count}</h3>
<button onClick={() => setCount(count + 1)}>Click</button>
</>
);
};
it('click', async () => {
// the same methods as from `@tramvai/test-react`
const { fireEvent, screen } = testComponent(<Component />);
expect(screen.getByRole('heading').textContent).toBe('0');
await fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('heading').textContent).toBe('1');
});
DI providers
As many other tramvai
testing utilities, you can pass custom DI or providers to testComponent
:
/**
* @jest-environment jsdom
*/
import { createToken } from '@tramvai/core';
import { useDi } from '@tramvai/react';
import { testComponent, screen } from '@tramvai/test-react';
const CONTENT_TOKEN = createToken<string>('content');
const Component = () => {
const content = useDi(CONTENT_TOKEN);
return <div data-testid="test">{content}</div>;
};
it('di', () => {
testComponent(<Component />, {
providers: [{ provide: CONTENT_TOKEN, useValue: 'Content from DI' }],
});
expect(screen.getByTestId('test').textContent).toBe('Content from DI');
});
Connected components
testComponent
allow you to provide any stores or initial state and will return context
instance for events or actions runs:
/**
* @jest-environment jsdom
*/
import { createReducer, createEvent, useStore } from '@tramvai/state';
import { testComponent } from '@tramvai/test-react';
const incrementEvent = createEvent('increment');
const CountStore = createReducer('count', 0).on(incrementEvent, (state) => state + 1);
const Component = () => {
const count = useStore(CountStore);
return <h3>{count}</h3>;
};
it('state', async () => {
const { context, act, screen } = testComponent(<Component />, {
stores: [CountStore],
});
expect(screen.getByRole('heading').textContent).toBe('0');
// act is required for react@18 concurrent features, we need to wait for state update and component rerender
await act(() => {
context.dispatch(incrementEvent());
});
expect(screen.getByRole('heading').textContent).toBe('1');
});
Hooks
testHook
API looks looks very similar to testComponent
:
/**
* @jest-environment jsdom
*/
import { useStore } from '@tramvai/state';
import { testHook } from '@tramvai/test-react';
const useCountStore = () => {
return useStore(CountStore);
};
it('hook', async () => {
const { result, context, act } = testHook(() => useCountStore(), {
stores: [CountStore],
});
expect(result.current).toBe(0);
await act(() => {
context.dispatch(incrementEvent());
});
expect(result.current).toBe(1);
});
Integration tests
Deprecated setup and examples for integration tests, this section will be rewritten in favour to direct Playwright usage (without Jest)
For any web applications, comprehensive testing requires running that application in a browser. For SSR applications, another main requirement is to build and start server code of the application.
We will use jest
and playwright
in examples below.
tramvai
provides few packages for integration testing:
@tramvai/test-integration
responsible for running application@tramvai/test-integration-jest
containsjest
configuration@tramvai/test-pw
provides wrappers for testing application in-browser
Setup test suite
Test suite in jest
terms is the root describe
block in the test file. We need to build and run application once for test suite, and close application server when test suite is finished.
@tramvai/test-integration
export startCli
method, which works similar to tramvai new
command. This API allows to configure application build, for example provide any environment variables or run application on different port.
startCli
makes development build of the application
Let's create a minimal test suite example:
import type { StartCliResult } from '@tramvai/test-integration';
import { startCli } from '@tramvai/test-integration';
describe('testing-app', () => {
// `startCli` will return some useful testing API's, which we will use later in test cases
let app: StartCliResult;
// build and run once
beforeAll(async () => {
// pass application name, which will be resolved from `tramvai.json`
app = await startCli('testing-app', {
// pass any environment variables
env: {
SOME_ENV: 'test',
},
});
// timeout depends on your application build time
}, 80000);
// close after end
afterAll(() => {
return app.close();
});
});
Test without browser
For testing requests to the tramvai app pages (aka curl
) libraries supertest and node-html-parser are used under hood.
Call of app.request
sends requests to the app. All of the features of supertest
are available.
Call of app.render
resolves to the HTML render that is returned from server while serving the request.
For example, we want to check that the page /
returns 200 status code and application content in the HTML:
it('request', async () => {
// `supertest` API, send request to root page
const response = await app
.request('/')
// test status code
.expect(200)
// test headers
.expect('X-App-Id', 'testing-app');
// test application content
expect(response.text).toMatch('<div class="application">rootPage</div>');
});
As alternative to response.text
, we can test parsed HTML result:
it('render', async () => {
const page = await app.render('/');
// test application content, already parsed
expect(page.application).toMatch('rootPage');
// or use `node-html-parser` API, which is similar to DOM API
expect(page.parsed.querySelector('.application').innerHTML).toMatch('rootPage');
});
Usage of @tinkoff/mocker
in tests
In order to use mocker there should be added @tramvai/module-mocker
to the tramvai app modules list.
@tramvai/module-mocker
works by replacing mocked API env variables when application starts.
You can pass list of mocked env variables directly in MockerModule
, and it will not affect production code, for integration tests all requests to API without specific mocks just will be proxied to original env value:
MockerModule.forRoot({
config: async () => ({
apis: ['AWESOME_API'],
}),
});
Or add a file in the mocks
folder:
module.exports = {
api: 'AWESOME_API',
mocks: {},
};
app.mocker.addMocks
will have no effect if mocked API (method first argument) not in the list!
After that mocker will read file based mocks as described in the docs to the mocker itself and it can be used dynamically in the tests:
it('should work with mocker', async () => {
// AWESOME_API - env variable with target API base url
await app.mocker.addMocks('AWESOME_API', {
// api endpoint method, pathname and response
'GET /endpoint/': {
status: 200,
payload: {
status: 'OK',
response: 'smth',
},
},
});
await app.request('/some-page/').expect(200);
// clear HTTP clients cache for fresh requests
await app.papi.clearCache();
await app.mocker.removeMocks('AWESOME_API', ['GET /endpoint/']);
await app.request('/some-page/').expect(500);
});
Papi testing
For papi methods testing you can use app.papi
wrapper methods publicPapi
and privatePapi
with all supertest
features.
For example, let's make request to built-in papi method which returns all application routes in payload:
it('papi', async () => {
const response = await app.papi.publicPapi('bundleInfo').expect(200);
expect(response.body).toMatchInlineSnapshot(`
{
"payload": [
"/",
],
"resultCode": "OK",
}
`);
});
Playwright
Setup
playwright
instance need to be initialized in the start of the test suite, and closed in the end:
import { initPlaywright } from '@tramvai/test-pw';
it('playwright', async () => {
// pass application url, usually http://localhost:3000
const { browser } = await initPlaywright(app.serverUrl);
// ...
return browser.close();
});
New page
Default example, open /second-page/
application url in browser, step by step:
import {
initPlaywright,
wrapPlaywrightPage,
} from '@tramvai/test-pw';
it('playwright', async () => {
const { browser } = await initPlaywright(app.serverUrl);
// open empty browser tab
const page = await browser.newPage();
// wrapper required for better logs
const wrapper = wrapPlaywrightPage(page);
// equivalent to navigate browser tab to "http://localhost:3000/second-page/" url
await page.goto(`${app.serverUrl}/second-page/`);
return browser.close();
});
The same, but simplified example:
it('playwright', async () => {
const { browser, getPageWrapper } = await initPlaywright(app.serverUrl);
const { page } = await getPageWrapper(`${app.serverUrl}/second-page/`);
return browser.close();
});
Navigation
Page wrapper return special router
object, which works directly with tramvai
router on the page:
it('playwright', async () => {
const { browser, getPageWrapper } = await initPlaywright(app.serverUrl);
const { router } = await getPageWrapper(`${app.serverUrl}/second-page/`);
// SPA-navigation with SpaRouterModule, hard reload with NoSpaRouterModule.
// equivalent to `pageService.navigate('/third-page/')`
router.navigate('/third-page/');
// update current url or router params without reloading, if possible.
// equivalent to `pageService.updateCurrentRoute({ query: { foo: 'bar' } })`
router.updateCurrentRoute({ query: { foo: 'bar' } });
return browser.close();
});
Page interaction
playwright
provides a lot of different API's, here is some useful for testing:
- page.evaluate is main method for execute code in the page context.
- page.$eval is alias over page
document.querySelector
.
In example below we will check .application
element content with both methods:
it('playwright', async () => {
const { browser, getPageWrapper } = await initPlaywright(app.serverUrl);
const { page } = await getPageWrapper(app.serverUrl);
const pageUrl = await page.evaluate(() => window.location.pathname);
const pageContent = await page.$eval('.application', (node) => node.innerHTML);
expect(pageUrl).toEqual(`/`);
expect(pageContent).toEqual('Main page');
return browser.close();
});
Client-side requests interception
For example, we want to block any requests to https://www.test.api.example
:
it('playwright', async () => {
const { browser, getPageWrapper } = await initPlaywright(app.serverUrl);
const { page } = await getPageWrapper(app.serverUrl);
page.route('**/*', (route) => {
if (route.request().url() === 'https://www.test.api.example/') {
route.fulfill({
status: 500,
contentType: 'text/plain',
body: 'Blocked',
});
} else {
route.continue();
}
});
return browser.close();
});