Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add integration testing with server-side rendering #4123

Merged
merged 10 commits into from
Sep 8, 2022
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v2.1.5
with:
node-version: '12'
node-version: '14'

- name: Run CI
run: |
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ coverage

# Bundle visualizer
stats.html

# Snapshots error images
__tests__/integration/snapshots/*-diff.png
__tests__/integration/snapshots/*-diff.svg
54 changes: 53 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,58 @@
有任何疑问,欢迎提交 [issue](https://github.com/antvis/g2/issues),
或者直接修改提交 [PR](https://github.com/antvis/g2/pulls)!

## 开发

> TODO

## 测试

### 单元测试

> TODO

### 集成测试

和单元测试测试单独的可视化组件不同,集成测试用于测试整个可视化图表。所有的测试案例在 `__tests__/integration/charts` 里面,同时在 `__tests__/integration/index.ts` 里面注册。

每次新增一个测试案例的时候只需要在 `__tests__/integration/charts` 目录下新增一个文件,命名格式为 `[数据名字]-[测试点]`。该文件导出一个函数,该函数的名字和文件名的驼峰形式保持一致,同时返回一个 G2 的 options。这个函数可以是同步的,也可以是异步的。

```js
// __tests__/integration/charts/sales-basic-interval.ts
import { data } from '../data/sales';

export async function salesBasicInterval() {
return {
type: 'interval',
data,
encode: {
x: 'genre',
y: 'sold',
color: 'genre',
},
};
}
```

创建好对应的测试案例之后需要 `__tests__/integration/index.ts` 中注册。

```js
// __tests__/integration/charts/index.ts
export { salesBasicInterval } from './sales-basic-interval';
```

之后运行 `npm run dev`,这时候可以通过打开的浏览器预览图表。确保图表和预览效果保持一致后,运行 `npm run test:integration` 进行集成测试。

如果一切没有问题的话,会在 `__tests__/integration/snapshots` 额外下生成 `[数据名字]-[测试点].png` 和 `[数据名字]-[测试点].svg` 两个基准图片,用于之后的测试。

```text
__tests__/integration/snapshots/salesBasicInterval.png
__tests__/integration/snapshots/salesBasicInterval.svg
```

如果测试案例不通过,则会生成 `-diff` 标记的图片。如果该图片复合预期,那么删除基准图片,重新运行 `npm run test:integration` 生成新的基准图片;否者修改代码,直到通过测试。


## 提交 issue

- 请确定 issue 的类型。
Expand Down Expand Up @@ -144,4 +196,4 @@ G2 基于 [semver] 语义化版本号进行发布。
[release proposal mr]: https://github.com/nodejs/node/pull/4181
[node changelog]: https://github.com/nodejs/node/blob/master/CHANGELOG.md
[npm]: http://npmjs.com/
[『我是如何发布一个 npm 包的』]: https://fengmk2.com/blog/2016/how-i-publish-a-npm-package
[『我是如何发布一个 npm 包的』]: https://fengmk2.com/blog/2016/how-i-publish-a-npm-package
74 changes: 74 additions & 0 deletions __tests__/integration/canvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as fs from 'fs';
import { PNG } from 'pngjs';
import { createCanvas } from 'canvas';
import pixelmatch from 'pixelmatch';
import { Canvas } from '@antv/g';
import { Renderer } from '@antv/g-canvas';
import { render, G2Spec } from '../../src';

export async function renderCanvas(
options: G2Spec,
filename: string,
defaultWidth = 640,
defaultHeight = 480,
) {
const { width = defaultWidth, height = defaultHeight } = options;
const [canvas, nodeCanvas] = createGCanvas(width, height);
await new Promise<void>((resolve) => {
render(options, { canvas }, resolve);
});
// Wait for the next tick.
await sleep(20);
await writePNG(nodeCanvas, filename);
return canvas;
}

/**
* diff between PNGs
*/
export function diff(src: string, target: string) {
const img1 = PNG.sync.read(fs.readFileSync(src));
const img2 = PNG.sync.read(fs.readFileSync(target));
const { width, height } = img1;
return pixelmatch(img1.data, img2.data, null, width, height, {
threshold: 0.1,
});
}

function createGCanvas(width: number, height: number) {
// Create a node-canvas instead of HTMLCanvasElement
const nodeCanvas = createCanvas(width, height);
// A standalone offscreen canvas for text metrics
const offscreenNodeCanvas = createCanvas(1, 1);

// Create a renderer, unregister plugin relative to DOM.
const renderer = new Renderer();
const domInteractionPlugin = renderer.getPlugin('dom-interaction');
renderer.unregisterPlugin(domInteractionPlugin);

return [
new Canvas({
width,
height,
canvas: nodeCanvas as any,
renderer,
offscreenCanvas: offscreenNodeCanvas as any,
}),
nodeCanvas,
] as const;
}

function sleep(n: number) {
return new Promise((resolve) => {
setTimeout(resolve, n);
});
}

function writePNG(nodeCanvas, path) {
return new Promise<void>((resolve, reject) => {
const out = fs.createWriteStream(path);
const stream = nodeCanvas.createPNGStream();
stream.pipe(out);
out.on('finish', resolve).on('error', reject);
});
}
1 change: 1 addition & 0 deletions __tests__/integration/charts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { salesBasicInterval } from './sales-basic-interval';
13 changes: 13 additions & 0 deletions __tests__/integration/charts/sales-basic-interval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { data } from '../data/sales';

export function salesBasicInterval() {
return {
type: 'interval',
data: data,
encode: {
x: 'genre',
y: 'sold',
color: 'genre',
},
};
}
7 changes: 7 additions & 0 deletions __tests__/integration/data/sales.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const data = [
{ genre: 'Sports', sold: 275 },
{ genre: 'Strategy', sold: 115 },
{ genre: 'Action', sold: 120 },
{ genre: 'Shooter', sold: 350 },
{ genre: 'Other', sold: 150 },
];
63 changes: 63 additions & 0 deletions __tests__/integration/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as fs from 'fs';
import * as tests from './charts';
import { renderCanvas, diff } from './canvas';
import { renderSVG } from './svg';

describe('integration', () => {
for (const [name, generateOptions] of Object.entries(tests)) {
it(`[Canvas]: ${name}`, async () => {
let canvas;
try {
const actualPath = `${__dirname}/snapshots/${name}-diff.png`;
const expectedPath = `${__dirname}/snapshots/${name}.png`;
const options = await generateOptions();

// Generate golden png if not exists.
if (!fs.existsSync(expectedPath)) {
console.warn(`! generate ${name}`);
canvas = await renderCanvas(options, expectedPath);
} else {
canvas = await renderCanvas(options, actualPath);
expect(diff(actualPath, expectedPath)).toBe(0);

// Persevere the diff image if do not pass the test.
fs.unlinkSync(actualPath);
}
} finally {
canvas.destroy();
}
});
}

for (const [name, generateOptions] of Object.entries(tests)) {
it(`[SVG]: ${name}`, async () => {
let canvas;
let actual;
try {
const expectedPath = `${__dirname}/snapshots/${name}.svg`;
const options = await generateOptions();
[canvas, actual] = await renderSVG(options);

// Generate golden svg if not exists.
if (!fs.existsSync(expectedPath)) {
console.warn(`! generate ${name}`);
fs.writeFileSync(expectedPath, actual);
} else {
const expected = fs.readFileSync(expectedPath, {
encoding: 'utf8',
flag: 'r',
});
expect(expected).toBe(actual);
}
} catch (error) {
// Generate error svg to compare.
console.warn(`! generate ${name}`);
const diffPath = `${__dirname}/snapshots/${name}-diff.svg`;
fs.writeFileSync(diffPath, actual);
throw error;
} finally {
canvas.destroy();
}
});
}
});
18 changes: 18 additions & 0 deletions __tests__/integration/jsdom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import util from 'util';

// @see https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
// @see https://github.com/jsdom/jsdom/issues/2524
if (global.window) {
// eslint-disable-next-line no-undef
Object.defineProperty(global.window, 'TextEncoder', {
writable: true,
value: util.TextEncoder,
});
// eslint-disable-next-line no-undef
Object.defineProperty(global.window, 'TextDecoder', {
writable: true,
value: util.TextDecoder,
});
}

export { JSDOM } from 'jsdom';
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions __tests__/integration/snapshots/salesBasicInterval.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 67 additions & 0 deletions __tests__/integration/svg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import xmlserializer from 'xmlserializer';
import { Canvas } from '@antv/g';
import { Renderer } from '@antv/g-svg';
import { createCanvas } from 'canvas';
import { G2Spec, render } from '../../src';
import { JSDOM } from './jsdom';

export async function renderSVG(
options: G2Spec,
defaultWidth = 640,
defaultHeight = 480,
) {
const { width = defaultWidth, height = defaultHeight } = options;
const [canvas, dom] = createGCanvas(width, height);
await new Promise<void>((resolve) => {
render(options, { canvas }, resolve);
});

// Wait for the next tick.
await sleep(20);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests utils 中有 delay 方法


const svg = xmlserializer.serializeToString(
//@ts-ignore
dom.window.document.getElementById('container').children[0],
);
return [canvas, svg] as const;
}

function createGCanvas(width: number, height: number) {
const dom = new JSDOM(`
<div id="container">
</div>
`);
// @ts-ignore
global.window = dom.window;
// @ts-ignore
global.document = dom.window.document;

// A standalone offscreen canvas for text metrics
const offscreenNodeCanvas = createCanvas(1, 1);

// Create a renderer, unregister plugin relative to DOM.
const renderer = new Renderer();
const domInteractionPlugin = renderer.getPlugin('dom-interaction');
renderer.unregisterPlugin(domInteractionPlugin);

return [
new Canvas({
container: 'container',
width,
height,
renderer,
// @ts-ignore
document: dom.window.document,
offscreenCanvas: offscreenNodeCanvas as any,
requestAnimationFrame: dom.window.requestAnimationFrame,
cancelAnimationFrame: dom.window.cancelAnimationFrame,
}),
dom,
] as const;
}

export function sleep(n: number) {
return new Promise((resolve) => {
setTimeout(resolve, n);
});
}
4 changes: 2 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ module.exports = {
preset: 'ts-jest/presets/js-with-ts',
globals: {
'ts-jest': {
tsConfig: {
tsconfig: {
target: 'esnext', // Increase test coverage.
allowJs: true,
sourceMap: true,
},
},
},
collectCoverage: false,
testRegex: '(/__tests__/.*\\.(test|spec))\\.ts$',
testRegex: '(/__tests__/unit/.*\\.(test|spec))\\.ts$',
collectCoverageFrom: ['src/**/*.ts', '!**/d3-sankey/**', '!**/d3-cloud/**'],
// Transform esm to cjs.
transformIgnorePatterns: [`<rootDir>/node_modules/(?!(${esm}))`],
Expand Down
24 changes: 24 additions & 0 deletions jest.node.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Installing third-party modules by tnpm or cnpm will name modules with underscore as prefix.
// In this case _{module} is also necessary.
const esm = ['internmap', 'd3-*', 'lodash-es']
.map((d) => `_${d}|${d}`)
.join('|');

module.exports = {
testTimeout: 30000,
preset: 'ts-jest/presets/js-with-ts',
globals: {
'ts-jest': {
tsconfig: {
target: 'es6',
allowJs: true,
sourceMap: true,
},
},
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
collectCoverage: false,
testRegex: '(/__tests__/integration/.*\\.(test|spec))\\.(ts|tsx|js)$',
// Transform esm to cjs.
transformIgnorePatterns: [`<rootDir>/node_modules/(?!(${esm}))`],
};