Skip to content

Commit

Permalink
add sample components for ssr unit test (#312)
Browse files Browse the repository at this point in the history
* tests and snapshot updated

* add example component ssr unit test

* Sample cmpts

* More cleanup

* Cleanup

* final changes: test and doc updated,setup warn issue fix

---------

Co-authored-by: lturanscaia <[email protected]>
  • Loading branch information
abhagta-sfdc and lesya7 authored Sep 18, 2024
1 parent 5dc071c commit 54a7cb6
Show file tree
Hide file tree
Showing 38 changed files with 607 additions and 223 deletions.
114 changes: 114 additions & 0 deletions example-ssr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# SSR component Unit test setup

To ensure high-quality rendering for both server and client, separate test suites are necessary:

- **Server-side tests:** Focus on rendering components to static HTML on the server side. These tests are executed in a Node environment, ensuring that server-side logic works as expected without client-side DOM interactions.
- **Client-side tests:** Validate how components behave post-hydration in a browser-like environment. These tests are executed using JSDOM to simulate browser behavior.

Combining these tests in the same suite is complex and increases maintenance efforts. By separating them, we ensure better test reliability and coverage.

## Jest configuration

Jest is the primary tool used for testing, and both "core" and "off-core" repositories rely on it for server-side and client-side tests. The test configuration differs for each environment.

### Server-side configuration

In server-side testing, we focus on generating static HTML on the server. Below is a sample configuration file for server-side testing:

`jest.ssr-server.config.js`

```js
module.exports = {
displayName: 'Server-side rendering',
preset: '@lwc/jest-preset/ssr-server',
testMatch: ['**/*.ssr-server.(spec|test).(js|ts)'],
collectCoverageFrom: ['**/*.ssr-server.(spec|test).(js|ts)'],
};
```

### Client-side configuration

For client-side testing, we validate how the component behaves after the client-side hydration. Below is a sample configuration for client-side-rendering testing.

`jest.ssr-client.config.js`

```js
module.exports = {
displayName: 'SSR with hydration',
preset: '@lwc/jest-preset/ssr-for-hydration',
setupFilesAfterEnv: ['./jest.ssr-client.setupAfterEnv.js'],
testMatch: ['**/*.ssr-client.(spec|test).(js|ts)'],
transformIgnorePatterns: ['node_modules/(?!(@webcomponents/.+)/)'],
};
```

At present, hydration errors are tracked by monitoring the console.warn event.

`jest.ssr-client.setupAfterEnv.js`

```js
let hydrationMismatchOccurred = false;
let hydrationMismatchMessage = '';

beforeEach(() => {
// Reset the flag and message before each test
hydrationMismatchOccurred = false;
hydrationMismatchMessage = '';

// Spy on console.warn and intercept warnings
jest.spyOn(console, 'warn').mockImplementation((message) => {
if (message.includes('Hydration mismatch')) {
// Set the flag to indicate a hydration mismatch occurred
hydrationMismatchOccurred = true;
// Store the hydration mismatch message
hydrationMismatchMessage = message;
} else {
// If it's not a hydration mismatch, call the original console.warn
console.warn(message);
}
});
});

afterEach(() => {
// Restore original console.warn after each test
jest.restoreAllMocks();

// Check if a hydration mismatch occurred and fail the test if so
if (hydrationMismatchOccurred) {
throw new Error(`Test failed due to hydration mismatch: ${hydrationMismatchMessage}`);
}
});
```

### Main Jest configuration

The main Jest configuration file combines both server-side and client-side test setups using the "projects" feature in Jest.

```js
module.exports = {
projects: ['<rootDir>/jest.ssr-server.config.js', '<rootDir>/jest.ssr-client.config.js'],
};
```

### Writing test

**Server-side snapshot generation** :
Server-side tests generate static HTML markup as snapshots. These snapshots are critical in verifying the consistency of server-rendered output.

- Step 1: Run server-side tests to generate initial snapshots.
- Step 2: When component changes occur, rerun side-server tests to identify markup changes. If discrepancies arise, the tests will fail.
- Step 3: Update the snapshots once changes are confirmed valid.

**Hydration using snapshots** :
Client-side tests utilize server-side snapshots to validate the post-hydration behavior of components:

- Read server-side-generated snapshots.
- Insert the pre-rendered markup into the DOM.
- Hydrate the component and validate its behavior in the client environment.

**Snapshot management** :

- Snapshot hash: Every part of the snapshot associated with a table-driven test case is linked to a unique hash, generated from the component's tag name, properties, and state. This approach ensures the integrity between server-side and client-side tests by enabling precise comparisons during hydration, ensuring that each test case aligns with its expected rendered output.
- Updating snapshots: After modifying a component, update the corresponding snapshot to ensure alignment with changes.

For more details about how snapshots are generated, updated, and how to use APIs like generateAndSnapshotMarkup and readSnapshotMarkup, refer to [ snapshot utils](../packages/@lwc/jest-ssr-snaphot-utils/README.md).
7 changes: 7 additions & 0 deletions example-ssr/jest.ssr-client.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
displayName: 'SSR with hydration',
preset: '@lwc/jest-preset/ssr-for-hydration',
setupFilesAfterEnv: ['./jest.ssr-client.setupAfterEnv.js'],
testMatch: ['**/*.ssr-client.(spec|test).(js|ts)'],
transformIgnorePatterns: ['node_modules/(?!(@webcomponents/.+)/)'],
};
31 changes: 31 additions & 0 deletions example-ssr/jest.ssr-client.setupAfterEnv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
let hydrationMismatchOccurred = false;
let hydrationMismatchMessage = '';

beforeEach(() => {
// Reset the flag and message before each test
hydrationMismatchOccurred = false;
hydrationMismatchMessage = '';

// Spy on console.warn and intercept warnings
jest.spyOn(console, 'warn').mockImplementation((message) => {
if (message.includes('Hydration mismatch')) {
// Set the flag to indicate a hydration mismatch occurred
hydrationMismatchOccurred = true;
// Store the hydration mismatch message
hydrationMismatchMessage = message;
} else {
// If it's not a hydration mismatch, call the original console.warn
console.warn(message);
}
});
});

afterEach(() => {
// Restore original console.warn after each test
jest.restoreAllMocks();

// Check if a hydration mismatch occurred and fail the test if so
if (hydrationMismatchOccurred) {
throw new Error(`Test failed due to hydration mismatch: ${hydrationMismatchMessage}`);
}
});
6 changes: 6 additions & 0 deletions example-ssr/jest.ssr-server.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
displayName: 'Server-side rendering',
preset: '@lwc/jest-preset/ssr-server',
testMatch: ['**/*.ssr-server.(spec|test).(js|ts)'],
collectCoverageFrom: ['**/*.ssr-server.(spec|test).(js|ts)'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<x-basic> should render on the server (props = {"msg": "Hello, Universe!"}): 34628b2c2f600c2775bdf4131a576fcf6bc07e003cef10d0d523ff923d7af4e2 1`] = `"<x-basic><template shadowrootmode="open"><h1>Basic, Hello, Universe!</h1></template></x-basic>"`;
exports[`<x-basic> should render on the server (props = {"msg": "Hello, world!"}): ebf35c654b2d8178d7dd129e79ae74bd36c7f5bc3c496babc46f60da73dae93b 1`] = `"<x-basic><template shadowrootmode="open"><h1>Basic, Hello, world!</h1></template></x-basic>"`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<x-basic> should render on the server: 4da3f8ca30774dda15f0aa526a0101c3229e8df7b695c739f96bcf1bc52d3ddd 1`] = `"<x-basic><template shadowrootmode="open"><h1>Basic, Welcome!</h1></template></x-basic>"`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { readSnapshotMarkup } from '@lwc/jest-ssr-snapshot-utils';
import { hydrateShadowRoots } from '@webcomponents/template-shadowroot';
import { hydrateComponent } from '@lwc/engine-dom';
import Basic from '../basic';
import tests from './ssr';

describe('<x-basic>', () => {
let wrapper;
beforeEach(() => {
// Create and append the wrapper element before each test
wrapper = document.createElement('div');
document.body.appendChild(wrapper);
});

afterEach(() => {
// Remove the wrapper element after each test
if (wrapper) {
document.body.removeChild(wrapper);
}
});

it.each(tests)('should render on the client (props = $props)', async ({ props }) => {
// Retrieve and set the snapshot markup
const markup = readSnapshotMarkup('x-basic', props);
expect(markup).not.toBeNull();
wrapper.innerHTML = markup;

// Hydrate shadow roots and component
hydrateShadowRoots(wrapper);

const componentEl = wrapper.firstElementChild;
expect(componentEl).toBeInstanceOf(HTMLElement);
expect(componentEl).toHaveProperty('shadowRoot');

hydrateComponent(componentEl, Basic, props);

// Query the h1 element inside the shadow root
const shadowRoot = componentEl.shadowRoot;
expect(shadowRoot).not.toBeNull();

const h1El = shadowRoot.querySelector('h1');
expect(h1El).not.toBeNull();

// Validate that the message is correctly displayed
expect(h1El.textContent).toContain(props.msg);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Basic from '../basic';
import { renderAndHashComponent } from '@lwc/jest-ssr-snapshot-utils';
import tests from './ssr';

describe('<x-basic>', () => {
it.each(tests)('should render on the server (props = $props)', async ({ props }) => {
const { renderedComponent, snapshotHash } = renderAndHashComponent('x-basic', Basic, props);
expect(renderedComponent).toMatchSnapshot(snapshotHash);
});
});
46 changes: 46 additions & 0 deletions example-ssr/src/modules/x/basic/__tests__/basic.ssr-client.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { readSnapshotMarkup } from '@lwc/jest-ssr-snapshot-utils';
import { hydrateShadowRoots } from '@webcomponents/template-shadowroot';
import { hydrateComponent } from '@lwc/engine-dom';
import Basic from '../basic';

describe('<x-basic>', () => {
let wrapper;
beforeEach(() => {
// Create and append the wrapper element before each test
wrapper = document.createElement('div');
document.body.appendChild(wrapper);
});

afterEach(() => {
// Remove the wrapper element after each test
if (wrapper) {
document.body.removeChild(wrapper);
}
});

test('should hydrate on the client', () => {
// Retrieve and set the snapshot markup
const markup = readSnapshotMarkup('x-basic', { msg: 'Welcome!' });
expect(markup).not.toBeNull();
wrapper.innerHTML = markup;

// Hydrate shadow roots and component
hydrateShadowRoots(wrapper);

const componentEl = wrapper.firstElementChild;
expect(componentEl).toBeInstanceOf(HTMLElement);
expect(componentEl).toHaveProperty('shadowRoot');

hydrateComponent(componentEl, Basic, { msg: 'Welcome!' });

// Query the h1 element inside the shadow root
const shadowRoot = componentEl.shadowRoot;
expect(shadowRoot).not.toBeNull();

const h1El = shadowRoot.querySelector('h1');
expect(h1El).not.toBeNull();

// Validate that the message is correctly displayed
expect(h1El.textContent).toContain('Welcome!');
});
});
11 changes: 11 additions & 0 deletions example-ssr/src/modules/x/basic/__tests__/basic.ssr-server.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Basic from '../basic';
import { renderAndHashComponent } from '@lwc/jest-ssr-snapshot-utils';

describe('<x-basic>', () => {
test('should render on the server', async () => {
const { renderedComponent, snapshotHash } = renderAndHashComponent('x-basic', Basic, {
msg: 'Welcome!',
});
expect(renderedComponent).toMatchSnapshot(snapshotHash);
});
});
10 changes: 10 additions & 0 deletions example-ssr/src/modules/x/basic/__tests__/ssr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default [
{
props: { msg: 'Hello, Universe!' },
expected: { msg: 'Hello, Universe!' },
},
{
props: { msg: 'Hello, world!' },
expected: { msg: 'Hello, world!' },
},
];
3 changes: 3 additions & 0 deletions example-ssr/src/modules/x/basic/basic.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>Basic, {msg}</h1>
</template>
5 changes: 5 additions & 0 deletions example-ssr/src/modules/x/basic/basic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { LightningElement, api } from 'lwc';

export default class Basic extends LightningElement {
@api msg;
}
4 changes: 2 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function integration({ nativeShadow }) {
},

rootDir: '<rootDir>/test',
preset: '@lwc/jest-preset/ssr-for-hydration',
preset: '@lwc/jest-preset',
moduleNameMapper: {
'^smoke/(.+)$': '<rootDir>/src/modules/smoke/$1/$1',
'^(components)/(.+)$': '<rootDir>/src/modules/$1/$2/$2',
Expand All @@ -57,7 +57,7 @@ module.exports = {
},

rootDir: '<rootDir>/test',
preset: '@lwc/jest-preset/ssr-server',
preset: '@lwc/jest-preset/ssr',
moduleNameMapper: {
'^ssr/(.+)$': '<rootDir>/src/modules/ssr/$1/$1',
},
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"scripts": {
"prepare": "husky",
"test": "jest --no-cache",
"test:ssr": "jest --no-cache --projects=./example-ssr/jest.ssr-server.config.js",
"test:csr": "jest --no-cache --projects=./example-ssr/jest.ssr-client.config.js",
"clean": "lerna run clean && lerna clean --yes && rm -rf node_modules",
"lint": "eslint packages/ test/ --ext=js,mjs,ts",
"format": "prettier --write '{packages,test}/**/*.{js,mjs,ts,json,md,yaml}'",
Expand Down Expand Up @@ -36,6 +38,9 @@
"prettier": "^2.0.0",
"semver": "^7.6.0"
},
"dependencies": {
"@webcomponents/template-shadowroot": "0.2.1"
},
"lint-staged": {
"**/*.{js,mjs,ts}": "eslint --fix",
"**/*.{js,mjs,ts,json,md,yaml}": "prettier --write"
Expand Down
1 change: 1 addition & 0 deletions packages/@lwc/jest-preset/ssr-for-hydration/jest-preset.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ const originalCSRPreset = require('../jest-preset.js');
module.exports = {
...originalCSRPreset,
testEnvironment: require.resolve('@lwc/jest-jsdom-test-env'),
snapshotSerializers: [],
};
2 changes: 1 addition & 1 deletion packages/@lwc/jest-preset/ssr-server/jest-preset.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ const originalSSRPreset = require('../ssr/jest-preset.js');

module.exports = {
...originalSSRPreset,
snapshotSerializers: ['jest-serializer-html'],
snapshotSerializers: [],
};
Loading

0 comments on commit 54a7cb6

Please sign in to comment.