E2E(End to End) Test
์ค์ ์ฌ์ฉ์๊ฐ ์ฌ์ฉํ๋ ๋ธ๋ผ์ฐ์ ํ๊ฒฝ์์ ์ ํ๋ฆฌ์ผ์ด์
์ ์ฒด๋ฅผ ๋์ํด์ ๊ธฐ๋ฅ์ ํ์ธํ๋ ํ
์คํธ
์ฆ, ์ฌ์ฉ์๊ฐ ์ค์ ๋ก ์ํํธ์จ์ด๋ฅผ ์ฌ์ฉํ์๋ ํ๋ก์ฐ๋ฅผ ํ
์คํธํ๋ ๊ฒ
E2E Test ํน์ง
์ค์ ์ฌ์ฉ์ ํ๊ฒฝ์์ ํ
์คํธํ๋ฏ๋ก ์ฌ์ฉ์์ ์
์ฅ์์์ ๊ฒฝํ์ ์ง์ ์ ์ผ๋ก ํ
์คํธํ ์ ์๋ค.
์ ์ฒด ์์คํ
์ ๋์์ผ๋ก ํ
์คํธ๋ฅผ ์ํํ๊ธฐ ๋๋ฌธ์ ๊ฐ๊ฐ์ ๋ชจ๋์ด๋ ์ปดํฌ๋ํธ๊ฐ ํจ๊ป ์๋ํ ๋ ๋ฐ์ํ๋ ๋ฌธ์ ์ ๋ค์ ํ์
๊ฐ๋ฅ
์ธ๋ถ ํ๊ฒฝ์ด๋ ๋คํธ์ํฌ๋ฅผ ์ด์ฉํ๋ค๋ฉด ์กฐ๊ธ ๋ถ์์ ํ ์ ์๋ค.
E2E Test Framework
Cypress
Cypress
์ ์์ ์ฝ๋๋ฅผ ์ดํด๋ณด๋ฉด UI ์์๋ฅผ ๊ฐ์ ธ์ฌ ๋ ๋ด๋ถ ๊ตฌํ ์ฌํญ (์: class)์ ์์กดํ๊ณ ์๋ค.
testing-library๋ Cypress๋ ์ง์ํ๋ฏ๋ก ํจ๊ป ์ฌ์ฉํ๋ฉด UI ์์๋ฅผ ์ฌ์ฉ์ ๊ด์ ์์ ๊ฐ์ ธ์ฌ ์ ์๋ค
Copy npm i -D @testing-library/cypress
E2E ํ
์คํธ์์๋ ๋ง์ฐฌ๊ฐ์ง๋ก, ๋คํธ์ํฌ ๊ด๋ จ ์์กด์ฑ๋ค์ Mock ๋ฐ์ดํฐ๋ฅผ ํ์ฉํ์ฌ ํ
์คํธ๋ฅผ ์งํํ๋ค.
์ด๋ ์ฌ์ฉ์์ ์ค์ ํ๊ฒฝ๊ณผ ์ ์ฌํ ํ๊ฒฝ์์ ํ
์คํธ๋ฅผ ์ํํ๋๋ผ๋ ์ธ๋ถ ์์กด์ฑ์ ์ํด ํ
์คํธ๊ฐ ์คํจํ๋ ๊ฒ์ ๋ฐฉ์งํ๊ธฐ ์ํจ์ด๋ค.
Copy import '@testing-library/cypress/add-commands'
describe ( 'App' , () => {
beforeEach (() => {
cy .intercept ( 'GET' , '/restaurants' , {
fixture : 'restaurants.json' ,
})
cy .visit ( 'http://localhost:3001/' );
})
it ( 'display text search in the header' , () => {
cy .findByText ( /search/ ) .should ( 'exist' );
})
})
Headless Chrome
Headless ์ํ์์๋ ํฌ๋กฌ ๋ธ๋ผ์ฐ์ ๋ฅผ ์คํํ ์ ์๋ ๋ฐฉ๋ฒ, ํฌ๋กฌ์์ด ํฌ๋กฌ์ ์คํํ๋ ๊ฒ์ด๋ผ๊ณ ์ค๋ช
ํ๋๋ฐ ํฌ๋กค๋ง์ ํ๊ฑฐ๋ E2E ํ
์คํธํ ๋ ์ ์ฉํ๋ค๊ณ ํ๋ค.
Chrome ๋ธ๋ผ์ฐ์ ์ ๊ธฐ๋ฅ์ ๊ทธ๋๋ก ๊ฐ์ง๊ณ ์์ง๋ง, ์ค์ ๋ก ํ๋ฉด์ ๊ทธ๋ฆฌ์ง ์๊ณ ๋ด๋ถ์ ์ผ๋ก๋ง ์คํ๋๋ ๋ชจ๋
์ฆ, GUI -> CLI
headless๋?
Headless๋ ์ผ๋ฐ์ ์ผ๋ก ์ฌ์ฉ์ ์ธํฐํ์ด์ค (UI)๊ฐ ์๋ ์์ฉ ํ๋ก๊ทธ๋จ ์ ์๋ฏธ
"์น ๊ฐ๋ฐ์์๋ ๋ธ๋ผ์ฐ์ ์ UI๋ฅผ ๋ ๋ํ์ง ์๊ณ ํ์ํ๋๋ธ๋ผ์ฐ์ ๋ชจ๋๋ฅผ ์๋ฏธํ๊ธฐ๋ ํ๋ค.
์ด ๋ชจ๋์์๋ ์น ์ฌ์ดํธ๋ฅผ ๋ธ๋ผ์ฐ์ ์ฐฝ์ด๋ ํญ ์์ด ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์คํํ ์ ์์ผ๋ฉฐ, ๋๊ฐ ์๋ํ๋ ์น ํ
์คํธ๋ ์น ์คํฌ๋ํ ์์
๋ฑ์์ ์ฌ์ฉ๋๋ค"
์ผ๋ฐ์ ์ผ๋ก headless๋ก ๋์ํ๋ฉด ํ๋ฉด์ UI๋ฅผ ๋ ๋๋งํ์ง ์์ผ๋ฏ๋ก ํ
์คํธ ์คํ์๋๊ฐ ๋ ๋น ๋ฅด๋ค.
CI/CD ํ์ดํ๋ผ์ธ์์ ์๋ํ๋ ํ
์คํธ๋ฅผ ์ํํ ๋ ์ ์ฉํ๋ค.
Puppeteer
Google์์ ๊ฐ๋ฐํ Node.js์ฉ Headless Chrome ๋ผ์ด๋ธ๋ฌ๋ฆฌ
Puppeteer ๋ Headless Chrome์ ์๋ฐ์คํฌ๋ฆฝํธ๋ก ์ ์ดํ์ฌ ์น ํฌ๋กค๋ง, ์คํฌ๋ฆฐ์ท, E2E ํ
์คํธ ๋ฑ ๋ค์ํ ์์
์ ์๋ํํ ์ ์๋ค.
Chrome DevTools Protocol
์ ์ฌ์ฉํ์ฌ Chrome ๋ธ๋ผ์ฐ์ ์ ์ธ์คํด์ค๋ฅผ ์ ์ดํ๋ฉฐ, ์ด๋ฅผ ํตํด Chrome ๋ธ๋ผ์ฐ์ ์ ๋ชจ๋ ๊ธฐ๋ฅ์ ์ฌ์ฉํ ์ ์๋ค.
E2E ํ
์คํธ ๋๊ตฌ๋ก ๋ณด๋ค ๊ฐ๋จํ๊ณ ํ
์คํธ ์ฝ๋ ์์ฑ์ ๋น๊ฐ๋ฐ์๋ ์ฝ๊ฒ ์ดํดํ ์ ์๋๋ก ์ง๊ด์ ์ธ API๋ฅผ ์ ๊ณตํ๋ค.
์ฅ์
๊ฐ๋
์ฑ์ด ๋์ DSL(Domain Specific Language)์ ์ ๊ณตํ์ฌ ํ
์คํธ ์ฝ๋ ์์ฑ์ด ์ฉ์ดํจ
Selenium, Puppeteer, Playwright ๋ฑ, ๋ค์ํ ๋๋ผ์ด๋ฒ๋ฅผ ์ ๊ณตํ์ฌ ๋ค๋ฅธ ๋๊ตฌ๋ก ์์ฑ๋ ํ
์คํธ ์ฝ๋๋ฅผ CodeceptJS
๋ก ๋ณํ์ด ๊ฐ๋ฅํ๋ค.
Getting Started
Copy npx create-codeceptjs .
ํด๋น ๋ช
๋ น์ด๋ฅผ ์
๋ ฅํ๋ฉด package.json ์คํ์ด์ค๊ฐ 4์นธ์ผ๋ก ๋ฐ๋๊ฒ ๋๋ค. ์๋ ๋ช
๋ น์ด๋ฅผ ํตํด ๋ณต๊ตฌ!
Copy sed "s/ / /g" package.json > package.json.tmp
rm package.json
mv package.json.tmp package.json
package.json
์ script๊ฐ ์๋์ผ๋ก ์ถ๊ฐ๋๋ค.
Copy "scripts" : {
// ์น ๋ธ๋ผ์ฐ์ ๋ฅผ ํ๋ฉด์ ๋์ ํ
์คํธ๋ฅผ ์คํํฉ๋๋ค. --steps: ๊ฐ ๋จ๊ณ์ ์คํ ๊ฒฐ๊ณผ๊ฐ ์์ธํ ํ์
"codeceptjs" : "codeceptjs run --steps" ,
// ์น ๋ธ๋ผ์ฐ์ ๋ฅผ ํ๋ฉด์ ๋์ฐ์ง ์๊ณ ํ
์คํธ๋ฅผ ์คํ
"codeceptjs:headless" : "HEADLESS=true codeceptjs run --steps" ,
// ์น ๋ธ๋ผ์ฐ์ ์ CodeceptUI๋ฅผ ๋์ ํจ์ฌ ํธํ๊ฒ ํ
์คํธ๋ฅผ ์คํํฉ๋๋ค.
"codeceptjs:ui" : "codecept-ui --app" ,
// demo๋ ๊ธฐ๋ฅ๊ณผ ์ฌ์ฉ๋ฒ์ ์ตํ๊ธฐ ์ํด codeceptjs๊ฐ ์ ๊ณตํ๋ ์ํ ์๋๋ฆฌ์ค๋ฅผ ์คํํ๋๊ฒ
"codeceptjs:demo" : "codeceptjs run --steps -c node_modules/@codeceptjs/examples" ,
"codeceptjs:demo:headless" : "HEADLESS=true codeceptjs run --steps -c node_modules/@codeceptjs/examples" ,
"codeceptjs:demo:ui" : "codecept-ui --app -c node_modules/@codeceptjs/examples"
}
Copy npm uninstall @codeceptjs/examples // ์์ ์ฝ๋ ์ ๊ฑฐ
Copy npx codeceptjs init
? Where are your tests located ? ( ./*_test.js )
# => ./tests/**/*_test.js
? What helpers do you want to use?
( Use arrow keys )
# => โฏ Playwright
? Where should logs, screenshots, and reports to be stored ? ( ./output )
# => ./output
? Do you want localization for tests ? ( See https://codecept.io/translation/ )
( Use arrow keys )
# => โฏ English (no localization)
? [Playwright] Base url of site to be tested ( http://localhost )
# => http://localhost:8080
? [Playwright] Show browser window ( Y/n )
# => Y
? [Playwright] Browser in which testing will be performed.
Possible options: chromium, firefox or webkit (chromium)
# => chromium
Creating a new test...
----------------------
? Feature which is being tested ( ex: account, login, etc )
# => google
? Filename of a test ( google_test.js )
# => google_test.js
์ํ ์ฝ๋
Copy Feature ( 'Google' );
Scenario ( 'Search โCodeceptJSโ' , ({ I }) => {
I .amOnPage ( 'https://www.google.com/ncr' );
I .fillField ( '[name="q"]' , 'CodeceptJS' );
I .click ( 'Google Search' );
I .see ( 'codecept.io' );
});
์คํ
Playwright
Headless Chrome์ ๊ธฐ๋ฐ์ผ๋ก ํ Puppteer์ ๊ณ์นํ๋ฉด์ ๋ ๋ง์ ์น ๋ธ๋ผ์ฐ์ ๋ฅผ ์ง์ํ๋ค.
Copy npm i -D @playwright/test eslint-plugin-playwright
ํ๊ฒฝ์ค์
playwright.config.ts
Copy import { PlaywrightTestConfig } from '@playwright/test' ;
const config : PlaywrightTestConfig = {
testDir : './tests' ,
retries : 0 ,
use : {
channel : 'chrome' ,
baseURL : 'http://localhost:8080' , // ๋๋ฉ์ธ URL ์ค์
headless : !! process . env . CI , // CI ํ๊ฒฝ์์ headless๋ก ๋์
screenshot : 'only-on-failure' , // ํ
์คํธ ์คํจํ ๋ ์คํฌ๋ฆฐ์ท์ ์ฐ์ด ๋จ๊น
} ,
};
export default config;
.gitignore ํ์ผ์ ์๋ฌ ์ํฉ์ ์คํฌ๋ฆฐ์ท ๋ฑ์ด ๋ด๊ธฐ๋ test-results/
๋๋ ํฐ๋ฆฌ ์ถ๊ฐ.
tests/.eslintrc.js
Copy module . exports = {
env : {
jest : false , // jest๋ฅผ ์ฌ์ฉํ์ง ์๊ณ playwright์์ ์ ๊ณตํ๋ api๋ฅผ ์ฌ์ฉํ๋ฏ๋ก false
} ,
extends : [ 'plugin:playwright/playwright-test' ] ,
rules : {
'import/no-extraneous-dependencies' : 'off' ,
} ,
}
jest.config.js
E2E ํ
์คํธ๋ .spec, ๋จ์ ํ
์คํธ๋ .test๋ก ๊ตฌ๋ถํ์ฌ ๋จ์ํ
์คํธ ์คํ์ E2E ํ
์คํธ๋ ์คํํ์ง ์๋๋ก ์ค์
Copy module . exports = {
//...
testMatch : [ '**/*.test.{ts,tsx}' , '!**/*.spec.{ts,tsx}' ] ,
// ๋๋
testPathIgnorePatterns : [ '<rootDir>/e2e/' ] ,
};
ํ
์คํธ ์ฝ๋ ์์
UI๊ฐ ๋ณต์กํด์ง๋ฉด ์ฌ์ฉ์ ๊ด์ ์์ ํน์ ์์๋ฅผ ์ง์ ํ๋ ๊ฒ์ด ์์ง์ ์ด๋ ค์ด๋ฐ ์ถํ์ ์๊ฒ๋๋ฉด ํด๋น ๋ด์ฉ์ ๋ฐ์ํด์ ๋ค์ ์์ฑํ ์์
Copy import { test , expect } from '@playwright/test' ;
test ( 'Filter Food' , async ({ page }) => {
await page .goto ( '/' );
await expect ( page .getByRole ( 'heading' , { name : '์๋น ๊ฒ์์ฐฝ' })) .toBeVisible ();
const searchInput = page .getByLabel ( '๊ฒ์' );
await searchInput .fill ( '๋ฉ๋ฆฌ๊น๋ฐฅ' );
await expect ( page .getByText ( '์ ์ก๊น๋ฐฅ' )) .toBeVisible ();
await searchInput .fill ( '๋ฉ๊ฐ๋ฐ์ ' );
await expect ( page .getByText ( '์ง์ฅ๋ฉด' )) .toBeVisible ();
await searchInput .fill ( '์๋ฌด๊ฑฐ๋' );
await expect ( page .getByText ( '์ง์ฅ๋ฉด' )) .toBeHidden ();
const radioBtn = page .getByRole ( 'radio' , { name : 'ํ์' });
await radioBtn .click ();
await expect ( page .getByText ( '๋ฉ๋ฆฌ๊น๋ฐฅ' )) .toBeVisible ();
await expect ( page .getByText ( '๋ฉ๊ฐ๋ฐ์ ' )) .toBeHidden ();
});
test ( 'Cart' , async ({ page }) => {
await page .goto ( '/' );
const addBtn = await page .waitForSelector ( 'button[name="#์ง์ฅ๋ฉด"]' );
await addBtn .click ();
await addBtn .click ();
await expect ( page .getByText ( 'ํฉ๊ณ: 8000์ ์ฃผ๋ฌธ' )) .toBeVisible ();
const removeBtn = page .getByRole ( 'button' , { name : '์ง์ฅ๋ฉด์ ๊ฑฐ' });
await removeBtn .click ();
await expect ( page .getByText ( 'ํฉ๊ณ: 0์ ์ฃผ๋ฌธ' )) .toBeVisible ();
});
test ( 'When order complete display receipt ' , async ({ page }) => {
await page .goto ( '/' );
const addBtn = await ( page .waitForSelector ( 'button[name="#์ง์ฅ๋ฉด"]' ));
await addBtn .click ();
const orderBtn = page .getByRole ( 'button' , { name : '์ฃผ๋ฌธํ๊ธฐ' });
await orderBtn .click ();
await expect ( page .getByRole ( 'table' , { name : '์์์ฆ' })) .toBeVisible ();
});
test ( 'Receipts disappear after 5 seconds' , async ({ page }) => {
await page .goto ( '/' );
const addBtn = await page .waitForSelector ( 'button[name="#์ง์ฅ๋ฉด"]' );
await addBtn .click ();
const orderBtn = page .getByRole ( 'button' , { name : '์ฃผ๋ฌธํ๊ธฐ' });
await orderBtn .click ();
// eslint-disable-next-line playwright/no-wait-for-timeout
await page .waitForTimeout ( 5000 );
await expect ( page .getByRole ( 'table' , { name : '์์์ฆ' })) .toBeHidden ();
await expect ( page .getByRole ( 'heading' , { name : '[์์์ฆ ๋์ค๋ ๊ณณ]' })) .toBeVisible ();
});
waitFor*
์ ๊ฐ์ ํน์ ์๊ฐ์ ๊ธฐ๋ค๋ฆฌ๋ API๋ฅผ ์ฌ์ฉํ ๋, ์ด์ ์ expect()๋ฅผ ์์ฑํ๋ค๋ฉด ๋์ํ์ง ์๋๋ค.