Di artikel sebelumnya sudah coba dibahas tentang unit-test, coverage-test, & snapshot-test menggunakan jest untuk menguji suatu component.
Salah satu kekurangan snapshot-test menggunakan jest adalah kita tidak bisa melihat component tersebut secara visual. Kita hanya bisa melihat source code component tersebut. Di artikel ini kita akan mencoba mempelajari tentang visual-regression-test sebagai lanjutan snapshot-test.
Visual-regression-test digunakan untuk melihat perubahan disuatu component secara visual dengan langsung menunjukan fokus di perubahannya.
Untuk memudahkan memahami kita akan membuat satu project sederhana menggunakan framework yang bisa digunakan untuk membuat component, yaitu svelte. Tools yang akan kita gunakan adalah storybook dengan berbagai addon-nya.

Storybook
Storybook ini sebenarnya bukan tools untuk testing. Storybook adalah tools untuk membuat component secara terpisah dari suatu aplikasi. Dengan storybook ini kita bisa membuat suatu component library yang nantinya bisa dipakai oleh berbagai aplikasi. Di dalam storybook ini kita bisa mendefinisikan stories di suatu component. Story ini merepresentasikan berbagai UI state yang mungkin terjadi di suatu component. Sebagai contoh di component button sebelumnya terdapat unit-test untuk berbagai type button apakah dia primary, secondary atau transparent.
it('primary', async () => {
expect.hasAssertions();
...
})it('secondary', async () => {
expect.hasAssertions();
...
})it('transparent', async () => {
expect.hasAssertions();
...
})
Unit-test ini akan kita ganti menjadi story di dalam Storybook, dan snapshot untuk masing-masing story tersebut akan otomatis ter-generate.
Experiment
Kita akan coba setup suatu project dan melakukan percobaan-percobaan di project ini. Ikuti langkah-langkah di bawah ini:
Setup Storybook
Project kali ini akan melanjutkan project di artikel sebelumnya, misal kita sebut project ini adalah svelte-storybook. Kita akan menambahkan beberapa package untuk keperluan storybook. Mari kita ikuti langkah-langkah dibawah ini:
- Di folder svelte-storybook kita bisa melakukan inisiasi storybook dengan menjalankan perintah untuk menginstall dependency-nya
npx -p @storybook/cli sb init — type svelte
- Lakukan perubahan file jest.config.js:
module.exports = {
transform: {
"^.+\\.js$": "babel-jest",
"^.+\\.svelte$": [
"svelte-jester",
{
debug: false,
},
],
"^.+\\.stories\\.[jt]sx?$": "<rootDir>node_modules/@storybook/addon-storyshots/injectFileName",
},
transformIgnorePatterns: ["node_modules/(?!@storybook/*)"],
bail: false,
verbose: true,
moduleFileExtensions: [
"js",
"svelte",
"json"
],
setupFilesAfterEnv: [
"@testing-library/jest-dom/extend-expect"
],
testPathIgnorePatterns: [
"/node_modules/",
"/build/",
"/storybook-static/"
],
coveragePathIgnorePatterns: [
"/node_modules/",
"/build/",
"/storybook-static/",
"/.storybook/",
],
coverageProvider: "babel",
collectCoverageFrom: [
"src/**/*.svelte"
],
coverageThreshold: {
"./src/components/": {
"branches": 80,
"functions": 100,
"lines": 100,
"statements": 100
},
},
coverageReporters: ["json", "html", "text", "clover"],
}
- Untuk keperluan automation snapshot-test kita perlu install satu tambahan addon lagi dengan perintah
npm i -D @storybook/addon-storyshots.
- Lalu buat file src/storybook.test.js
import initStoryshots from '@storybook/addon-storyshots';initStoryshots();
- Hapus folder stories didalam folder src karena kita kan melakukan experiment fokus ke component button yang telah kita buat sebelumnya.
- Coba jalankan perintah npm run storybook. Di console akan ditampilkan seperti berikut:

- Coba akses url tersebut di browser, seharusnya akan ditampilkan seperti berikut ini:

- Storybook berhasil kita jalankan, tetapi belum memiliki story apapun.
Selanjutnya akan kita coba membuat story di component button.
Add Story
Untuk membuat story cukup dengan membuat file dengan format nama_file_component.stories.js. Contoh dengan kasus component button di project ini, maka kita harus membuat file src/components/button.stories.js
import Button from "./button"export default {
title: 'Button',
excludeStories: /.*Data$/
}export const buttonData = {
title: "Button",
type: null,
click: null,
disabled: false,
loading: false,
}export const Default = () => ({
Component: Button,
props: {
data: buttonData
}
})
Setiap export const yang didefinisikan di file ini akan menjadi story. Tetapi kita bisa meng-exclude story mana saja yang tidak ingin ditampilkan dengan excludeStories, misal pada stories diatas terdapat excludeStories: /.*Data$/ yang digunakan untuk meng-exclude buttonData yang didalamnya memang hanya untuk data definition.
Coba sekarang kita akses url diatas, seharusnya akan ditampilkan seperti berikut ini:

Yeay, satu story dari component button telah berhasil kita buat.
Tapi bagaimana dengan coverage-test nya y? apa dengan story ini ada korelasinya dengan coverage-test? Perlu kita uji lagi. Untuk keperluan percobaan bisa dihapus atau rename file button.test.js & App.test.js supaya unit-test ini tidak dikenali oleh jest.
Selanjutnya coba kita jalankan perintah npm run test-cov.

Ternyata coverage-test nya memiliki nilai > 0, berarti story Default yang telah dibuat sebelumnya bisa menambah nilai coverage-test. Bisa kita simpulkan unit-test yang kita buat sebelumnya bisa diganti dengan story.
Baik, mari kita lakukan percobaan selanjutnya untuk membuat coverage-test tersebut menjadi 100%. Seperti cara analisa sebelumnya kita bisa melihat Uncovered Lines di output console ataupun menggunakan report dalam bentuk html.
- Html Report summary secara keseluruhan

- Html Report summary untuk file component button

- Html Report detail di component button


Cara membaca report ini sama dengan coverage-test sebelumnya. Bisa dilihat ada beberapa kondisi yang belum pernah diujikan.
- line 13: kondisi onclick yang null (tidak ada action click). untuk ini kita membutuhkan story loading & disabled
- line 20: kondisi button memiliki title. untuk ini kita membutuhkan story title
- line 21: kondisi type button. untuk ini kita membutuhkan story type-button
- line 79: seharusnya sudah terakomodir ketika 3 story diatas sudah dibuat.
Mari kita lengkapi story diatas sesuai dengan analisa kita. Update button.stories.js menjadi seperti berikut:
import { action } from '@storybook/addon-actions';import { type } from "../constants"
import Button from "./button"export default {
title: 'Button',
excludeStories: /.*Data$/
}export const buttonData = {
title: "Button",
type: null,
click: action('click'),
disabled: false,
loading: false,
}export const Default = () => ({
Component: Button,
props: {
data: buttonData
}
})export const EmptyProps = () => ({
Component: Button,
props: {
data: {}
}
})export const Loading = () => ({
Component: Button,
props: {
data: { ...buttonData, loading: true }
}
})export const Disabled = () => ({
Component: Button,
props: {
data: { ...buttonData, disabled: true }
}
})export const Primary = () => ({
Component: Button,
props: {
data: { ...buttonData, type: type.primary }
}
})export const Secondary = () => ({
Component: Button,
props: {
data: { ...buttonData, type: type.secondary }
}
})export const Transparent = () => ({
Component: Button,
props: {
data: { ...buttonData, type: type.transparent }
}
})
Dengan stories diatas, mari kita coba eksekusi kembali perintah npm run test-cov

Walaupun sudah didefinisikan seluruh kondisi, tetapi masih terdapat Uncovered line di line 77 terkait onClick. Nampaknya storybook belum bisa melakukan simulasi event onClick.
Kalau kalian bisa menemukan cara yang lebih proper, silakan koreksi stories diatas
OK, supaya tetap bisa mencapai coverage 100% kita harus menggunakan unit-test dengan cara sebelumnya. dengan file button.test.js
import { render, fireEvent, waitFor } from '@testing-library/svelte';
import { type } from "../constants"
import Button from './button.svelte';const testId = "button-tid";
const buttonData = {
title: null,
type: null,
click: null,
disabled: false,
loading: false,
}describe('Button', () => {
it('default', async () => {
expect.hasAssertions();const mockClick = jest.fn();
buttonData.click = mockClick;
const { getByTestId } = render(Button, { props: { data: buttonData } });
let button;
await waitFor(() => {
button = getByTestId(testId);
expect(button).toHaveTextContent("")
expect(button.classList.contains(type.primary)).toBe(true);
expect(button).toMatchSnapshot();
fireEvent.click(button);
})
expect(mockClick).toHaveBeenCalledTimes(1);
});it('loading, but not disabled', async () => {
expect.hasAssertions();const mockClick = jest.fn();
buttonData.click = mockClick;
const { getByTestId } = render(Button, { props: { data: { ...buttonData, loading: true, disabled: false } } });
let button
await waitFor(() => {
button = getByTestId(testId);
expect(button).not.toHaveAttribute('disabled')
expect(button).toHaveTextContent("Loading...")
expect(button).toMatchSnapshot();
fireEvent.click(button);
})
expect(mockClick).toHaveBeenCalledTimes(0);
})it('disabled, but not loading', async () => {
expect.hasAssertions();const mockClick = jest.fn();
buttonData.click = mockClick;
const { getByTestId } = render(Button, { props: { data: { ...buttonData, loading: false, disabled: true } } });
let button
await waitFor(() => {
button = getByTestId(testId);
expect(button).toHaveAttribute('disabled')
expect(button).not.toHaveTextContent("Loading...")
expect(button).toMatchSnapshot();
fireEvent.click(button);
})
expect(mockClick).toHaveBeenCalledTimes(0);
})it('not disabled, not loading', async () => {
expect.hasAssertions();const mockClick = jest.fn();
buttonData.click = mockClick;
const { getByTestId } = render(Button, { props: { data: { ...buttonData, loading: false, disabled: false } } });
let button
await waitFor(() => {
button = getByTestId(testId);
expect(button).not.toHaveAttribute('disabled')
expect(button).not.toHaveTextContent("Loading...")
expect(button).toMatchSnapshot();
fireEvent.click(button);
})
expect(mockClick).toHaveBeenCalledTimes(1);
})it('disabled, loading', async () => {
expect.hasAssertions();const mockClick = jest.fn();
buttonData.click = mockClick;
const { getByTestId } = render(Button, { props: { data: { ...buttonData, loading: true, disabled: true } } });
let button
await waitFor(() => {
button = getByTestId(testId);
expect(button).toHaveAttribute('disabled')
expect(button).toHaveTextContent("Loading...")
expect(button).toMatchSnapshot();
fireEvent.click(button);
})
expect(mockClick).toHaveBeenCalledTimes(0);
})
});
Lalu coba jalankan npm run test-cov

Sekarang component button sudah tembus coverage 100%.
Lanjut ke pembuatan story untuk component App. Buat file App.stories.js
import App from './App.svelte';
import { buttonData } from './components/button.stories';export default {
title: 'App',
excludeStories: /.*Data$/
}export const Default = () => ({
Component: App,
props: {
data: buttonData,
}
});
Lalu coba jalankan npm run test-cov

Owalaa, akhirnya bisa tembus coverage 100% untuk semua unit-test/stories.
Kesimpulan yang bisa diambil dari experiment ini adalah, dengan menggunakan storybook kita bisa lebih mudah membuat suatu component. Tanpa perlu banyak memikirkan unit-test. Unit-test ini nantinya akan di-handle oleh storybook dengan story nya.
Namun perlu diperhatikan, dari percobaan diatas, tidak semua kondisi bisa di-handle oleh storybook, misal untuk action event seperti click, ternyata belum bisa di-handle oleh storybook sehingga untuk coverage tidak bisa mencapai 100%. Tetapi bisa disolusikan dengan membuat unit-test dengan Jest & Testing-Library.
Snapshot-Test
Di storybook dengan bantuan addon @storybook/addon-storyshots snapshot sudah otomatis ter-generate ketika kita membuat suatu story. Secara default, config addon-storyshots akan menyimpan hasil snapshot di folder src/__snapshots__. Hasil snapshot bisa kita lihat di file src/__snapshots__/storybook.test.js.snap.

oiy, jika kalian menemukan error terkait snapshot yang tidak sesuai, bisa dicoba update snapshot dengan npm run test -- -u
Visual-Regression-Test
Penjelasan singkat visual-regression-test adalah teknik testing dengan membandingkan snapshot lama dengan snapshot baru. Ketika ada perubahan maka akan dilakukan pointing ke bagian yang berubah. Hal ini membantu kita untuk mengidentifikasi perubahan walaupun perubahan tersebut sangat kecil. Misal, terjadi perubahan style di bagian padding sebesar 1px. Dengan hanya penglihatan visual manusia tentu akan sulit mendeteksi perubahan tersebut.

Storybook memiliki tools tersendiri untuk melakukan visual-regression-test. Berbayar, namun bisa gratis dengan limitasi kuota snapshot. Di percobaan kita kali ini akan mencoba dengan cara lain, sekedar untuk memberikan gambaran visual-regression-test. Untuk percobaan ini kita perlu menambahkan addon baru npm i -D puppeteer @storybook/addon-storyshots-puppeteer dan merubah config addon addon-storyshots
import initStoryshots from '@storybook/addon-storyshots'
import { imageSnapshot } from '@storybook/addon-storyshots-puppeteer'const beforeScreenshot = (page) => {
return page.$('#root > *')
}
const getScreenshotOptions = ({ context, url }) => {
return {
encoding: 'base64',
fullPage: false,
}
}initStoryshots({
test: imageSnapshot({
storybookUrl: 'http://localhost:6006',
beforeScreenshot,
getScreenshotOptions,
}),
})
untuk menjalankan visual-regression-test kita perlu menjalankan storybook terlebih dahulu, karena yang akan kita capture adalah sebuah visual dalam bentuk image. Coba kita jalankan npm run storybook kemudian jalankan npm run test. Setelah selesai, kita akan melihat di dalam folder src terdapat folder __image_snapshots__. Di folder ini disimpan hasil-hasil snapshot yang nantinya akan dipakai untuk melakukan visual-regression-test.
Sekarang coba kita lakukan perubahan di salah satu element button. Misal kita ganti warna text button secondary menjadi hijau. lalu jalankan npm run test, maka akan ter-generate folder tambahan didalam __image_snapshots__ yaitu folder __diff_output__. coba kita perhatikan hasil visual-regression-test tersebut

- snapshot paling kiri adalah button secondary sebelum dilakukan perubahan
- snapshot paling kanan adalah button secondary setelah dilakukan perubahan
- snapshot yang berada ditengah adalah perubahan yang berhasil di-capture saat proses visual-regression-test. diperlihatkan dengan warna merah bercampur dengan kuning.
Seperti snapshot-test sebelumnya, ketika perubahan ini memang expected changes, kita bisa meng-update snapshot ini supaya menjadi acuan visual-regression-test berikutnya.
OK, demikian percobaan-percobaan yang telah kita lakukan sambil memahami dari awal apa itu unit-test, coverage-test, snapshot-test, dan akhirnya sampai di visual-regression-test.
Semoga bisa jadi bahan pembelajaran berikutnya.
Source code bisa dilihat di sini.