diff --git a/.gitignore b/.gitignore index 7d19c014c..d0ac7bd1a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ ### Environment Variable ### .env +### playwright authentication context +.auth + *.crt *.csr *.key diff --git a/frontend/e2eTests/category.spec.ts b/frontend/e2eTests/category.spec.ts deleted file mode 100644 index b90bfe42a..000000000 --- a/frontend/e2eTests/category.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { test, expect } from '@playwright/test'; - -import { createCategory, deleteCategory, getCategoryButton } from './category.actions'; -import { loginToCodezap, waitForSuccess } from './utils'; - -test.beforeEach(async ({ page }) => { - await loginToCodezap({ - page, - id: process.env.PLAYWRIGHT_TEST_ID || '', - password: process.env.PLAYWRIGHT_TEST_PASSWORD || '', - }); -}); - -test('카테고리 편집 모달에서 새 카테고리를 추가 및 삭제할 수 있다.', async ({ page, browserName }) => { - const newCategoryName = `생성테스트-${browserName}`; - - await createCategory({ page, categoryName: newCategoryName }); - - await waitForSuccess({ page, apiUrl: '/categories' }); - - const newCategoryButton = getCategoryButton({ page, categoryName: newCategoryName }); - - await expect(newCategoryButton).toBeVisible(); - - // 다음 테스트를 위해 테스트용 카테고리 삭제 - await deleteCategory({ page, categoryName: newCategoryName }); - - await waitForSuccess({ page, apiUrl: '/categories' }); - - await expect(newCategoryButton).not.toBeVisible(); -}); - -test('카테고리 편집 모달에서 카테고리명을 수정 및 삭제할 수 있다.', async ({ page, browserName }) => { - const newCategoryName = `수정테스트-${browserName}`; - const editedCategoryName = `수정완료-${browserName}`; - - // 수정할 카테고리 생성 - await createCategory({ page, categoryName: newCategoryName }); - - await waitForSuccess({ page, apiUrl: '/categories' }); - - const newCategoryButton = getCategoryButton({ page, categoryName: newCategoryName }); - - await expect(newCategoryButton).toBeVisible(); - - // 카테고리 수정 - await page.getByRole('button', { name: '카테고리 편집' }).click(); - - const newCategoryInEditModal = page.getByText(newCategoryName).nth(1); - - await newCategoryInEditModal.hover(); - await page.getByRole('button', { name: '카테고리 이름 변경' }).click(); - await page.getByPlaceholder('카테고리 입력').click(); - await page.getByPlaceholder('카테고리 입력').fill(editedCategoryName); - await page.getByRole('button', { name: '저장' }).click(); - - const editedCategoryButton = getCategoryButton({ page, categoryName: editedCategoryName }); - - await expect(editedCategoryButton).toBeVisible(); - - // 다음 테스트를 위해 테스트용 카테고리 삭제 - await deleteCategory({ page, categoryName: editedCategoryName }); - - await waitForSuccess({ page, apiUrl: '/categories' }); - await expect(editedCategoryButton).not.toBeVisible(); -}); - -test('카테고리는 최대 15글자까지만 입력할 수 있다.', async ({ page, browserName }) => { - const rawCategoryName = `최대글자수테스트-${browserName}`; - const expectedCategoryName = rawCategoryName.slice(0, 15); - - await page.getByRole('button', { name: '카테고리 편집' }).click(); - await page.getByRole('button', { name: '+ 카테고리 추가' }).click(); - const categoryInput = page.getByPlaceholder('카테고리 입력'); - - await categoryInput.click(); - - for (const char of rawCategoryName) { - await page.keyboard.type(char); - } - - await page.getByRole('button', { name: '저장' }).click(); - - await waitForSuccess({ page, apiUrl: '/categories' }); - - const newCategoryButton = getCategoryButton({ page, categoryName: expectedCategoryName }); - - await expect(newCategoryButton).toBeVisible(); - - // 다음 테스트를 위해 테스트용 카테고리 삭제 - await deleteCategory({ page, categoryName: expectedCategoryName }); - - await waitForSuccess({ page, apiUrl: '/categories' }); - await expect(newCategoryButton).not.toBeVisible(); -}); diff --git a/frontend/e2eTests/utils.ts b/frontend/e2eTests/utils.ts deleted file mode 100644 index a5d16e8e5..000000000 --- a/frontend/e2eTests/utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Page } from '@playwright/test'; - -interface LoginToCodezapProps { - page: Page; - id: string; - password: string; -} - -export const loginToCodezap = async ({ page, id, password }: LoginToCodezapProps) => { - await page.goto('/'); - await page.getByRole('link', { name: '로그인', exact: true }).getByRole('button').click(); - await page - .locator('div') - .filter({ hasText: /^아이디 \(닉네임\)$/ }) - .locator('div') - .click(); - await page.locator('input[type="text"]').fill(id); - await page.locator('input[type="text"]').press('Tab'); - await page.locator('input[type="password"]').fill(password); - await page.locator('form').getByRole('button', { name: '로그인' }).click(); - - await waitForSuccess({ page, apiUrl: '/login' }); -}; - -interface WaitForSuccessProps { - page: Page; - apiUrl: string; -} - -export const waitForSuccess = async ({ page, apiUrl }: WaitForSuccessProps) => { - await page.waitForResponse((response) => response.url().includes(apiUrl) && response.status() === 200); -}; diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index ab3e18af2..1bf28965d 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -20,7 +20,7 @@ const config: Config = { 'react-syntax-highlighter/dist/esm': 'react-syntax-highlighter/dist/cjs', }, transformIgnorePatterns: ['/node_modules/(?!react-syntax-highlighter)'], - testPathIgnorePatterns: ['/node_modules/', '/e2eTests/', '/tests-examples/'], + testPathIgnorePatterns: ['/playwright/'], }; export default config; diff --git a/frontend/package.json b/frontend/package.json index 87e96b285..23b0f083d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,8 @@ "main": "index.js", "scripts": { "test": "jest", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", "dev": "webpack-dev-server --config webpack.dev.js --open", "tsc": "tsc --noEmit", "build": "webpack --mode production --config webpack.prod.js", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index c820c7743..076c9a808 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -11,7 +11,7 @@ dotenv.config({ path: './.env.development' }); * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './e2eTests', + testDir: './playwright', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -34,19 +34,23 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' }, + dependencies: ['setup'], }, { name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' }, + dependencies: ['setup'], }, { name: 'webkit', - use: { ...devices['Desktop Safari'] }, + use: { ...devices['Desktop Safari'], storageState: 'playwright/.auth/user.json' }, + dependencies: ['setup'], }, /* Test against mobile viewports. */ diff --git a/frontend/playwright/tests/auth.setup.ts b/frontend/playwright/tests/auth.setup.ts new file mode 100644 index 000000000..c3b44a031 --- /dev/null +++ b/frontend/playwright/tests/auth.setup.ts @@ -0,0 +1,20 @@ +import { test as setup } from '@playwright/test'; +import path from 'path'; + +const authFile = path.join(__dirname, '../.auth/user.json'); + +setup('authenticate', async ({ page }) => { + const username = process.env.PLAYWRIGHT_TEST_USERNAME || ''; + const password = process.env.PLAYWRIGHT_TEST_PASSWORD || ''; + + await page.goto('/'); + await page.getByRole('button', { name: '로그인', exact: true }).click(); + await page.locator('input[type="text"]').fill(username); + await page.locator('input[type="text"]').press('Tab'); + await page.locator('input[type="password"]').fill(password); + await page.locator('form').getByRole('button', { name: '로그인' }).click(); + + await page.waitForURL('/my-templates'); + + await page.context().storageState({ path: authFile }); +}); diff --git a/frontend/e2eTests/category.actions.ts b/frontend/playwright/tests/category.actions.ts similarity index 100% rename from frontend/e2eTests/category.actions.ts rename to frontend/playwright/tests/category.actions.ts diff --git a/frontend/playwright/tests/category.spec.ts b/frontend/playwright/tests/category.spec.ts new file mode 100644 index 000000000..2a0b093fb --- /dev/null +++ b/frontend/playwright/tests/category.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from '@playwright/test'; + +import { createCategory, deleteCategory, getCategoryButton } from './category.actions'; +import { waitForSuccess } from './utils'; + +test('카테고리 편집 모달에서 새 카테고리를 추가 및 삭제할 수 있다.', async ({ page, browserName }) => { + await page.goto('/my-templates'); + + const newCategoryName = `생성테스트-${browserName}`; + + try { + await createCategory({ page, categoryName: newCategoryName }); + + await waitForSuccess({ page, apiUrl: '/categories' }); + + const newCategoryButton = getCategoryButton({ page, categoryName: newCategoryName }); + + await expect(newCategoryButton).toBeVisible(); + } catch (error) { + throw Error(error); + } finally { + await deleteCategory({ page, categoryName: newCategoryName }); + + await waitForSuccess({ page, apiUrl: '/categories' }); + + const newCategoryButton = getCategoryButton({ page, categoryName: newCategoryName }); + + await expect(newCategoryButton).not.toBeVisible(); + } +}); + +test('카테고리 편집 모달에서 카테고리명을 수정 및 삭제할 수 있다.', async ({ page, browserName }) => { + await page.goto('/my-templates'); + + const newCategoryName = `수정테스트-${browserName}`; + const editedCategoryName = `수정완료-${browserName}`; + + try { + // 수정할 카테고리 생성 + await createCategory({ page, categoryName: newCategoryName }); + + await waitForSuccess({ page, apiUrl: '/categories' }); + + const newCategoryButton = getCategoryButton({ page, categoryName: newCategoryName }); + + await expect(newCategoryButton).toBeVisible(); + + // 카테고리 수정 + await page.getByRole('button', { name: '카테고리 편집' }).click(); + + const newCategoryInEditModal = page.getByText(newCategoryName).nth(1); + + await newCategoryInEditModal.hover(); + await page.getByRole('button', { name: '카테고리 이름 변경' }).click(); + await page.getByPlaceholder('카테고리 입력').click(); + await page.getByPlaceholder('카테고리 입력').fill(editedCategoryName); + await page.getByRole('button', { name: '저장' }).click(); + + const editedCategoryButton = getCategoryButton({ page, categoryName: editedCategoryName }); + + await expect(editedCategoryButton).toBeVisible(); + } catch (error) { + throw Error(error); + } finally { + // 다음 테스트를 위해 테스트용 카테고리 삭제 + await deleteCategory({ page, categoryName: editedCategoryName }); + + const editedCategoryButton = getCategoryButton({ page, categoryName: editedCategoryName }); + + await waitForSuccess({ page, apiUrl: '/categories' }); + await expect(editedCategoryButton).not.toBeVisible(); + } +}); + +test('카테고리는 최대 15글자까지만 입력할 수 있다.', async ({ page, browserName }) => { + await page.goto('/my-templates'); + const rawCategoryName = `최대글자수테스트-${browserName}`; + const expectedCategoryName = rawCategoryName.slice(0, 15); + + try { + await page.getByRole('button', { name: '카테고리 편집' }).click(); + await page.getByRole('button', { name: '+ 카테고리 추가' }).click(); + const categoryInput = page.getByPlaceholder('카테고리 입력'); + + await categoryInput.click(); + + for (const char of rawCategoryName) { + await page.keyboard.type(char); + } + + await page.getByRole('button', { name: '저장' }).click(); + + await waitForSuccess({ page, apiUrl: '/categories' }); + + const newCategoryButton = getCategoryButton({ page, categoryName: expectedCategoryName }); + + await expect(newCategoryButton).toBeVisible(); + } catch (error) { + throw Error(error); + } finally { + // 다음 테스트를 위해 테스트용 카테고리 삭제 + await deleteCategory({ page, categoryName: expectedCategoryName }); + + const newCategoryButton = getCategoryButton({ page, categoryName: expectedCategoryName }); + + await waitForSuccess({ page, apiUrl: '/categories' }); + await expect(newCategoryButton).not.toBeVisible(); + } +}); diff --git a/frontend/e2eTests/search.actions.ts b/frontend/playwright/tests/search.actions.ts similarity index 100% rename from frontend/e2eTests/search.actions.ts rename to frontend/playwright/tests/search.actions.ts diff --git a/frontend/e2eTests/search.spec.ts b/frontend/playwright/tests/search.spec.ts similarity index 54% rename from frontend/e2eTests/search.spec.ts rename to frontend/playwright/tests/search.spec.ts index f0781192e..cf37599fb 100644 --- a/frontend/e2eTests/search.spec.ts +++ b/frontend/playwright/tests/search.spec.ts @@ -1,26 +1,24 @@ import { test, expect } from '@playwright/test'; import { searchTemplates } from './search.actions'; -import { loginToCodezap, waitForSuccess } from './utils'; - -test.beforeEach(async ({ page }) => { - await loginToCodezap({ - page, - id: process.env.PLAYWRIGHT_TEST_ID || '', - password: process.env.PLAYWRIGHT_TEST_PASSWORD || '', - }); -}); +import { waitForSuccess } from './utils'; + +test('검색창에 `검색테스트`를 입력하면 `검색테스트`가 내용에 포함된 템플릿 목록을 확인할 수 있다.', async ({ + page, +}) => { + await page.goto('/my-templates'); -test('검색창에 `테스트`를 입력하면 `테스트`가 내용에 포함된 템플릿 목록을 확인할 수 있다.', async ({ page }) => { - const keyword = '테스트'; + const keyword = '검색테스트'; await searchTemplates({ page, keyword }); await waitForSuccess({ page, apiUrl: '/templates?keyword' }); - await expect(page.getByRole('link', { name: /테스트/ })).toBeVisible(); + await expect(page.getByRole('link', { name: /검색테스트/ })).toBeVisible(); }); test('검색창에 `ㅁㅅㅌㅇ`를 입력할 경우 `검색 결과가 없습니다`가 나온다.', async ({ page }) => { + await page.goto('/my-templates'); + const keyword = 'ㅁㅅㅌㅇ'; await searchTemplates({ page, keyword }); diff --git a/frontend/playwright/tests/templates.actions.ts b/frontend/playwright/tests/templates.actions.ts new file mode 100644 index 000000000..9824a6645 --- /dev/null +++ b/frontend/playwright/tests/templates.actions.ts @@ -0,0 +1,75 @@ +import { expect, Page } from '@playwright/test'; + +import { waitForSuccess } from './utils'; + +/** + * + description과 tag는 필수 입력이 아닙니다. + */ +export const uploadTemplateToCodezap = async ({ + page, + title, + fileName, + code, + description, + tag, +}: { + page: Page; + title: string; + fileName: string; + code: string; + description?: string; + tag?: string; +}) => { + // 유저의 카테고리 리스트 + await page.waitForLoadState('networkidle'); + + await page.getByRole('button', { name: '새 템플릿' }).click(); + + // 제목 입력 + await page.getByPlaceholder('제목을 입력해주세요').fill(title); + + // 설명 입력 + if (description) { + await page.getByPlaceholder('이 템플릿을 언제 다시 쓸 것 같나요?').fill(description); + } + + // 파일명 입력 + await page.getByPlaceholder('파일명.js').fill(fileName); + + // 코드 입력 + await page + .locator('div') + .filter({ hasText: /^\/\/ 코드를 입력해주세요$/ }) + .nth(1) + .fill(code); + + // 태그 입력 + if (tag) { + await page.getByPlaceholder('enter 또는 space bar로 태그를 등록해보세요').fill(tag); + await page.getByPlaceholder('enter 또는 space bar로 태그를 등록해보세요').press('Enter'); + } + + // 저장 버튼 클릭 + await page.getByRole('button', { name: '저장' }).click(); + + await waitForSuccess({ page, apiUrl: '/templates' }); + + await expect(page).toHaveURL('/my-templates'); +}; + +interface deleteTemplateProps { + page: Page; + templateName: string; +} + +export const deleteTemplate = async ({ page, templateName }: deleteTemplateProps) => { + await page.getByRole('link', { name: templateName }).first().click(); + await page.getByRole('button', { name: '템플릿 삭제' }).click(); + + await expect(page.getByText('정말 삭제하시겠습니까?')).toBeVisible(); + + await page.getByRole('button', { name: '삭제', exact: true }).click(); + + await expect(page.getByRole('link', { name: ` ${templateName}` })).not.toBeVisible(); +}; diff --git a/frontend/playwright/tests/templates.spec.ts b/frontend/playwright/tests/templates.spec.ts new file mode 100644 index 000000000..c1f04a18f --- /dev/null +++ b/frontend/playwright/tests/templates.spec.ts @@ -0,0 +1,144 @@ +import { expect, test } from '@playwright/test'; + +import { deleteTemplate, uploadTemplateToCodezap } from './templates.actions'; +import { waitForSuccess } from './utils'; + +test('템플릿 업로드 시, 파일명을 입력하지 않으면 `파일명을 입력해주세요`라는 토스트 메시지가 나온다.', async ({ + page, +}) => { + await page.goto('/'); + await page.getByRole('button', { name: '새 템플릿' }).click(); + await page.getByPlaceholder('제목을 입력해주세요').fill('템플릿생성테스트'); + await page.getByPlaceholder('이 템플릿을 언제 다시 쓸 것 같나요?').fill('템플릿생성테스트'); + await page.getByRole('button', { name: '저장' }).click(); + + const toastMessage = page.locator('text=파일명을 입력해주세요'); + + await expect(toastMessage).toBeVisible(); +}); + +test('템플릿 제목, 설명, 파일명, 소스코드, 태그를 입력하고 저장버튼을 눌러 템플릿을 생성한다. 목록 페이지에서 새로 생성된 제목의 템플릿 카드를 확인할 수 있다.', async ({ + page, + browserName, +}) => { + await page.goto('/my-templates'); + + const testTitle = `템플릿생성테스트_${browserName}`; + + try { + await uploadTemplateToCodezap({ + page, + title: testTitle, + fileName: testTitle, + code: testTitle, + description: testTitle, + tag: testTitle, + }); + + const templateCard = page.getByRole('link', { name: `testTitle` }).first(); + + await expect(templateCard).toBeVisible(); + } catch (error) { + throw Error(error); + } finally { + await deleteTemplate({ page, templateName: testTitle }); + } +}); + +test('템플릿 카드를 누르면 템플릿 제목, 설명, 작성자, 생성날짜, 변경날짜, 카테고리, 코드 스니펫 목록을 확인할 수 있다.', async ({ + page, +}) => { + await page.goto('/my-templates'); + // 템플릿 목록 + await waitForSuccess({ page, apiUrl: '/templates' }); + + const templateCard = page.getByRole('link', { name: '상세조회테스트' }); + + await expect(templateCard).toBeVisible(); + await templateCard.click(); + + const title = page.getByText('상세조회테스트').first(); + const name = page.getByText('ll', { exact: true }); + const editedDate = page.getByText('2024년 9월 20일'); + const createdDate = page.getByText('(2024년 8월 21일)'); + const tag = page.getByRole('button', { name: '테스트' }); + const filename = page + .locator('div') + .filter({ hasText: /^test.ts$/ }) + .nth(1); + const sourceCodes = page.getByRole('textbox').getByText('// 함수'); + + await expect(title).toBeVisible(); + await expect(name).toBeVisible(); + await expect(editedDate).toBeVisible(); + await expect(createdDate).toBeVisible(); + await expect(tag).toBeVisible(); + await expect(filename).toBeVisible(); + await expect(sourceCodes).toBeVisible(); +}); + +test('`템플릿편집테스트` 템플릿의 제목을 `편집된템플릿`로 변경하고, `편집된템플릿`태그를 추가로 등록한다.', async ({ + page, + browserName, +}) => { + await page.goto('/my-templates'); + + const beforeTemplateTitle = `템플릿편집테스트-${browserName}`; + const afterTemplateTitle = `편집된템플릿-${browserName}`; + const addedTagName = `추가된태그-${browserName}`; + + try { + await uploadTemplateToCodezap({ + page, + title: beforeTemplateTitle, + fileName: beforeTemplateTitle, + code: beforeTemplateTitle, + description: beforeTemplateTitle, + tag: beforeTemplateTitle, + }); + + await page.getByRole('link', { name: beforeTemplateTitle }).first().click(); + + await page.getByRole('button', { name: '템플릿 편집' }).click(); + await page.getByPlaceholder('제목을 입력해주세요').fill(afterTemplateTitle); + await page.getByPlaceholder('enter 또는 space bar로 태그를 등록해보세요').fill(addedTagName); + await page.getByPlaceholder('enter 또는 space bar로 태그를 등록해보세요').press('Enter'); + await page.getByRole('button', { name: '저장' }).click(); + + await page.goto('/my-templates'); + + await expect(page.getByText(afterTemplateTitle).first()).toBeVisible(); + await expect(page.getByRole('button', { name: addedTagName }).first()).toBeVisible(); + } catch (error) { + throw Error(error); + } finally { + await deleteTemplate({ page, templateName: afterTemplateTitle }); + } +}); + +test('템플릿 삭제 버튼을 누르면 삭제 확인 모달이 뜨고, 삭제 확인 모달에서 삭제 버튼을 누르면, 템플릿이 삭제되고 내탬플릿 화면으로 이동한다.', async ({ + page, + browserName, +}) => { + await page.goto('/my-templates'); + + const testTitle = `템플릿삭제테스트-${browserName}`; + + await uploadTemplateToCodezap({ + page, + title: testTitle, + fileName: testTitle, + code: testTitle, + description: testTitle, + tag: testTitle, + }); + + await page.getByRole('link', { name: testTitle }).first().click(); + await page.getByRole('button', { name: '템플릿 삭제' }).click(); + + await expect(page.getByText('정말 삭제하시겠습니까?')).toBeVisible(); + + await page.getByRole('button', { name: '삭제', exact: true }).click(); + + await expect(page.getByRole('link', { name: testTitle })).not.toBeVisible(); +}); diff --git a/frontend/playwright/tests/utils.ts b/frontend/playwright/tests/utils.ts new file mode 100644 index 000000000..17d14d96f --- /dev/null +++ b/frontend/playwright/tests/utils.ts @@ -0,0 +1,12 @@ +import { Page } from '@playwright/test'; + +interface WaitForSuccessProps { + page: Page; + apiUrl: string; +} + +export const waitForSuccess = async ({ page, apiUrl }: WaitForSuccessProps) => { + await page.waitForResponse( + (response) => response.url().includes(apiUrl) && (response.status() === 200 || response.status() === 201), + ); +};