Skip to content

Commit 25c2005

Browse files
authored
test(fe): add playwright tests (#238)
* chore: add GitHub Actions workflow for PR testing * feat: add Playwright testing configuration and update scripts * fix: redirect to home instead of session on error * feat: add disabled state to Button component and update usage in SignIn/SignUp modals * feat: add Playwright tests * chore: update ESLint configuration to include playwright.config.ts * feat: install Playwright browsers in workflow * chore: update GitHub Actions workflow to use latest actions and improve dependency installation * chore: update Playwright browser installation path in GitHub Actions workflow * chore: remove redundant timeout and runs-on settings in GitHub Actions workflow
1 parent 3fd317f commit 25c2005

File tree

17 files changed

+951
-22
lines changed

17 files changed

+951
-22
lines changed

.github/workflows/test.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: PR Test
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
jobs:
8+
test:
9+
timeout-minutes: 60
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: actions/setup-node@v4
15+
with:
16+
node-version: lts/*
17+
18+
- name: Install dependencies
19+
run: npm install -g pnpm && pnpm install
20+
21+
- name: Install Playwright Browsers
22+
run: |
23+
cd apps/client
24+
pnpm exec playwright install --with-deps
25+
cd ../..
26+
27+
- name: Create .env file
28+
run: echo "${{ secrets.FRONT_END_ENV }}" > apps/client/.env
29+
30+
- name: Run tests
31+
run: pnpm run test

apps/client/.eslintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"dist",
66
"vite.config.ts",
77
"vite-env.d.ts",
8-
"routeTree.gen.ts"
8+
"routeTree.gen.ts",
9+
"playwright.config.ts"
910
],
1011
"extends": [
1112
"eslint:recommended",

apps/client/.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,9 @@ dist-ssr
2323
*.sln
2424
*.sw?
2525

26-
.env
26+
.env
27+
28+
/test-results/
29+
/playwright-report/
30+
/blob-report/
31+
/playwright/.cache/

apps/client/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
"build": "tsc -b && vite build",
99
"lint": "eslint . --fix",
1010
"format": "prettier --write .",
11-
"preview": "vite preview"
11+
"preview": "vite preview",
12+
"test": "pnpm exec playwright test --workers=1",
13+
"test-ui": "pnpm exec playwright test --ui",
14+
"test-debug": "pnpm exec playwright test --debug"
1215
},
1316
"dependencies": {
1417
"@tanstack/react-query": "^5.59.19",
@@ -28,10 +31,12 @@
2831
},
2932
"devDependencies": {
3033
"@eslint/js": "^9.11.1",
34+
"@playwright/test": "^1.49.0",
3135
"@tailwindcss/typography": "^0.5.15",
3236
"@tanstack/eslint-plugin-query": "^5.59.7",
3337
"@tanstack/router-devtools": "^1.78.3",
3438
"@tanstack/router-plugin": "^1.78.3",
39+
"@types/node": "^20.3.1",
3540
"@types/react": "^18.3.10",
3641
"@types/react-dom": "^18.3.0",
3742
"@typescript-eslint/eslint-plugin": "^7.18.0",

apps/client/playwright.config.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
/**
4+
* Read environment variables from file.
5+
* https://github.com/motdotla/dotenv
6+
*/
7+
// import dotenv from 'dotenv';
8+
// import path from 'path';
9+
// dotenv.config({ path: path.resolve(__dirname, '.env') });
10+
11+
/**
12+
* See https://playwright.dev/docs/test-configuration.
13+
*/
14+
export default defineConfig({
15+
testDir: './tests',
16+
/* Run tests in files in parallel */
17+
fullyParallel: true,
18+
/* Fail the build on CI if you accidentally left test.only in the source code. */
19+
forbidOnly: !!process.env.CI,
20+
/* Retry on CI only */
21+
retries: process.env.CI ? 2 : 0,
22+
/* Opt out of parallel tests on CI. */
23+
workers: process.env.CI ? 1 : 1,
24+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
25+
reporter: process.env.CI ? 'html' : 'line',
26+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
27+
webServer: {
28+
command: 'pnpm run dev',
29+
url: 'http://localhost:5173',
30+
reuseExistingServer: !process.env.CI,
31+
stdout: 'ignore',
32+
stderr: 'pipe',
33+
},
34+
use: {
35+
/* Base URL to use in actions like `await page.goto('/')`. */
36+
baseURL: 'http://127.0.0.1:5173',
37+
38+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
39+
trace: 'on-first-retry',
40+
},
41+
42+
/* Configure projects for major browsers */
43+
projects: [
44+
{
45+
name: 'chromium',
46+
use: { ...devices['Desktop Chrome'] },
47+
},
48+
49+
/* Test against mobile viewports. */
50+
// {
51+
// name: 'Mobile Chrome',
52+
// use: { ...devices['Pixel 5'] },
53+
// },
54+
// {
55+
// name: 'Mobile Safari',
56+
// use: { ...devices['iPhone 12'] },
57+
// },
58+
59+
/* Test against branded browsers. */
60+
// {
61+
// name: 'Microsoft Edge',
62+
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
63+
// },
64+
// {
65+
// name: 'Google Chrome',
66+
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
67+
// },
68+
],
69+
70+
/* Run your local dev server before starting the tests */
71+
// webServer: {
72+
// command: 'npm run start',
73+
// url: 'http://127.0.0.1:3000',
74+
// reuseExistingServer: !process.env.CI,
75+
// },
76+
});

apps/client/src/components/Button.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ import { forwardRef, HTMLAttributes, PropsWithChildren } from 'react';
22

33
type ButtonProps = PropsWithChildren<HTMLAttributes<HTMLButtonElement>>;
44

5-
const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
6-
const { children, className, onClick } = props;
5+
const Button = forwardRef<
6+
HTMLButtonElement,
7+
ButtonProps & { disabled?: boolean }
8+
>((props, ref) => {
9+
const { children, className, disabled, onClick } = props;
710

811
return (
912
<button
13+
disabled={disabled}
1014
ref={ref}
1115
className={`flex items-center justify-center rounded-md px-3 py-2 ${className}`}
1216
type='button'

apps/client/src/components/modal/SignInModal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ function SignInModal() {
6262
</div>
6363
</Button>
6464
<Button
65+
disabled={!isLoginEnabled}
6566
className={`transition-colors duration-200 ${isLoginEnabled ? 'bg-indigo-600' : 'cursor-not-allowed bg-indigo-300'}`}
6667
onClick={login}
6768
>

apps/client/src/components/modal/SignUpModal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ function SignUpModal() {
9292
</div>
9393
</Button>
9494
<Button
95+
disabled={!isSignUpButtonEnabled}
9596
className={`transition-colors duration-200 ${isSignUpButtonEnabled ? 'bg-indigo-600' : 'cursor-not-allowed bg-indigo-300'}`}
9697
onClick={handleSignUp}
9798
>

apps/client/src/routes/session/$sessionId/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const Route = createFileRoute('/session/$sessionId/')({
5555
chats.reverse().forEach(addChatting);
5656
} catch (e) {
5757
console.error(e);
58-
throw redirect({ to: '/session' });
58+
throw redirect({ to: '/' });
5959
}
6060
},
6161
});

apps/client/tests/HomePage.spec.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test.beforeEach(async ({ page }) => {
4+
await page.goto('/', { waitUntil: 'domcontentloaded' });
5+
});
6+
7+
test('헤더와 설명 텍스트가 올바르게 표시되는지 확인', async ({ page }) => {
8+
await expect(page.getByText('질문과 답변을 넘어,')).toBeVisible();
9+
await expect(page.getByText('함께 만드는 인사이트')).toBeVisible();
10+
await expect(
11+
page.getByText('실시간 Q&A와 소통을 위한 최적의 플랫폼'),
12+
).toBeVisible();
13+
});
14+
15+
test('기능 카드들이 모두 표시되는지 확인', async ({ page }) => {
16+
const features = [
17+
{ title: '실시간 Q&A', desc: '연사자와 익명의 청중의 실시간 응답' },
18+
{ title: '채팅', desc: '실시간 채팅으로 즉각적인 소통' },
19+
{
20+
title: '권한 관리',
21+
desc: '연사자와 참가자를 위한 세분화된 권한 시스템',
22+
},
23+
{ title: '아카이빙', desc: '세션 내용 보존과 효율적인 자료화' },
24+
];
25+
26+
await Promise.all(
27+
features.map(async (feature) => {
28+
await expect(
29+
page.locator(`text=${feature.title} >> .. >> text=${feature.desc}`),
30+
).toBeVisible();
31+
}),
32+
);
33+
});
34+
35+
test('회원가입 플로우 전체 테스트', async ({ page }) => {
36+
await page.click('text=회원가입');
37+
38+
const signUpButton = page.locator('text=회원 가입');
39+
await expect(signUpButton).toBeDisabled();
40+
41+
await page.route('**/api/users/emails/**', async (route) => {
42+
await route.fulfill({
43+
status: 200,
44+
contentType: 'application/json',
45+
body: JSON.stringify({ exists: false }),
46+
});
47+
});
48+
49+
await page.route('**/api/users/nicknames/**', async (route) => {
50+
await route.fulfill({
51+
status: 200,
52+
contentType: 'application/json',
53+
body: JSON.stringify({ exists: false }),
54+
});
55+
});
56+
57+
await page.route('**/api/users', async (route) => {
58+
await route.fulfill({
59+
status: 201,
60+
contentType: 'application/json',
61+
});
62+
});
63+
64+
await page.fill('input[placeholder="[email protected]"]', '[email protected]');
65+
await page.waitForResponse('**/api/users/emails/**');
66+
67+
await page.fill('input[placeholder="닉네임을 입력해주세요"]', 'testUser');
68+
await page.waitForResponse('**/api/users/nicknames/**');
69+
70+
await page.fill(
71+
'input[placeholder="비밀번호를 입력해주세요"]',
72+
'Password123!',
73+
);
74+
75+
await expect(signUpButton).toBeEnabled();
76+
77+
const response = page.waitForResponse('**/api/users');
78+
await signUpButton.click();
79+
expect((await response).status()).toBe(201);
80+
81+
await expect(page.locator('text=회원가입 되었습니다.')).toBeVisible();
82+
});
83+
84+
test('회원 가입이 이미 중복된 이메일이 있어서 실패하는 경우', async ({
85+
page,
86+
}) => {
87+
await page.click('text=회원가입');
88+
89+
const signUpButton = page.locator('text=회원 가입');
90+
await expect(signUpButton).toBeDisabled();
91+
92+
await page.route('**/api/users/emails/**', async (route) => {
93+
await route.fulfill({
94+
status: 200,
95+
contentType: 'application/json',
96+
body: JSON.stringify({ exists: true }),
97+
});
98+
});
99+
100+
await page.fill(
101+
'input[placeholder="[email protected]"]',
102+
103+
);
104+
await page.waitForResponse('**/api/users/emails/**');
105+
106+
await expect(page.locator('text=이미 사용 중인 이메일입니다.')).toBeVisible();
107+
await expect(signUpButton).toBeDisabled();
108+
});
109+
110+
test('회원 가입이 이미 중복된 닉네임이 있어서 실패하는 경우', async ({
111+
page,
112+
}) => {
113+
await page.click('text=회원가입');
114+
115+
const signUpButton = page.locator('text=회원 가입');
116+
await expect(signUpButton).toBeDisabled();
117+
118+
await page.route('**/api/users/emails/**', async (route) => {
119+
await route.fulfill({
120+
status: 200,
121+
contentType: 'application/json',
122+
body: JSON.stringify({ exists: false }),
123+
});
124+
});
125+
126+
await page.route('**/api/users/nicknames/**', async (route) => {
127+
await route.fulfill({
128+
status: 200,
129+
contentType: 'application/json',
130+
body: JSON.stringify({ exists: true }),
131+
});
132+
});
133+
134+
await page.fill(
135+
'input[placeholder="[email protected]"]',
136+
137+
);
138+
await page.waitForResponse('**/api/users/emails/**');
139+
await expect(page.locator('text=사용 가능한 이메일입니다.')).toBeVisible();
140+
141+
await page.fill('input[placeholder="닉네임을 입력해주세요"]', 'testUser');
142+
await page.waitForResponse('**/api/users/nicknames/**');
143+
await expect(page.locator('text=이미 사용 중인 닉네임입니다.')).toBeVisible();
144+
145+
await expect(signUpButton).toBeDisabled();
146+
});
147+
148+
test('로그인 / 로그아웃 플로우 전체 테스트', async ({ page }) => {
149+
await page.click('text=로그인');
150+
151+
const loginButton = page.locator('text=로그인').nth(1);
152+
153+
await page.fill('input[placeholder="[email protected]"]', '[email protected]');
154+
await page.fill(
155+
'input[placeholder="비밀번호를 입력해주세요"]',
156+
'Password123!',
157+
);
158+
159+
await expect(loginButton).toBeEnabled();
160+
161+
await page.route('**/api/auth/login', async (route) => {
162+
route.fulfill({
163+
status: 200,
164+
contentType: 'application/json',
165+
body: JSON.stringify({ accessToken: 'fake-jwt-token' }),
166+
});
167+
});
168+
169+
const response = page.waitForResponse('**/api/auth/login');
170+
await loginButton.click();
171+
expect((await response).status()).toBe(200);
172+
173+
await expect(page.locator('text=로그인 되었습니다.')).toBeVisible();
174+
175+
await page.click('text=로그아웃');
176+
await expect(page.locator('text=로그아웃 되었습니다.')).toBeVisible();
177+
});

0 commit comments

Comments
 (0)