Skip to content

Commit 0ca05d9

Browse files
authored
[25.07.13 / TASK-213] Feature - 프론트엔드 테스팅 리팩토링 및 고도화 (#46)
1 parent 98d11a8 commit 0ca05d9

31 files changed

+1771
-602
lines changed

cypress.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { defineConfig } from 'cypress';
2+
3+
export default defineConfig({
4+
e2e: {
5+
baseUrl: 'http://localhost:3000',
6+
supportFile: 'cypress/support/e2e.ts',
7+
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
8+
viewportWidth: 1920,
9+
viewportHeight: 1080,
10+
video: false,
11+
screenshotOnRunFailure: true,
12+
defaultCommandTimeout: 10000,
13+
requestTimeout: 10000,
14+
responseTimeout: 10000,
15+
env: {
16+
NEXT_PUBLIC_BASE_URL: 'http://localhost:3000',
17+
NEXT_PUBLIC_CHANNELTALK_PLUGIN_KEY: 'test_key',
18+
NEXT_PUBLIC_GA_ID: '',
19+
NEXT_PUBLIC_SENTRY_AUTH_TOKEN: 'test_sentry_token',
20+
NEXT_PUBLIC_SENTRY_DSN: 'test_sentry_dsn',
21+
},
22+
/* eslint-disable @typescript-eslint/no-unused-vars */
23+
setupNodeEvents(_on, _config) {
24+
// implement node event listeners here
25+
},
26+
},
27+
});

cypress/e2e/leaderboards.cy.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { BaseSuccess } from '../support';
2+
3+
describe('리더보드 페이지', () => {
4+
beforeEach(() => {
5+
cy.setAuthCookies();
6+
cy.visit('/leaderboards');
7+
});
8+
9+
it('페이지가 정상적으로 로드되어야 한다', () => {
10+
cy.waitForPageLoad();
11+
cy.url().should('include', '/leaderboards');
12+
});
13+
14+
it('사용자 리더보드가 표시되어야 한다', () => {
15+
cy.get('select').first().should('have.value', '사용자 기준');
16+
17+
cy.contains('user1').should('be.visible');
18+
cy.contains('user2').should('be.visible');
19+
20+
cy.contains('500').should('be.visible');
21+
cy.contains('300').should('be.visible');
22+
});
23+
24+
it('게시물 리더보드가 표시되어야 한다', () => {
25+
cy.get('select').first().select('게시글 기준');
26+
27+
cy.contains('인기 게시물 1').should('be.visible');
28+
cy.contains('인기 게시물 2').should('be.visible');
29+
30+
cy.contains('200').should('be.visible');
31+
cy.contains('150').should('be.visible');
32+
});
33+
34+
it('필터 기능이 동작해야 한다', () => {
35+
cy.get('select').should('have.length', 4);
36+
37+
cy.get('select').eq(1).select('좋아요 증가순');
38+
cy.get('select').eq(1).select('조회수 증가순');
39+
40+
cy.get('select').eq(2).select('30위까지');
41+
cy.get('select').eq(2).select('10위까지');
42+
43+
cy.get('select').eq(3).select('지난 7일');
44+
cy.get('select').eq(3).select('지난 30일');
45+
});
46+
47+
it('랭킹 순위가 표시되어야 한다', () => {
48+
cy.get('[data-testid="rank"], [class*="rank"]').should('be.visible');
49+
cy.contains('1').should('be.visible');
50+
cy.contains('2').should('be.visible');
51+
});
52+
53+
it('통계 변화량이 표시되어야 한다', () => {
54+
cy.contains('500').should('be.visible');
55+
cy.contains('300').should('be.visible');
56+
cy.contains('250').should('be.visible');
57+
58+
cy.get('select').eq(1).select('좋아요 증가순');
59+
cy.contains('50').should('be.visible');
60+
cy.contains('40').should('be.visible');
61+
});
62+
63+
it('빈 데이터 상태를 올바르게 처리해야 한다', () => {
64+
cy.intercept(
65+
'GET',
66+
'**/api/leaderboard/user*',
67+
BaseSuccess({ users: [] }, '사용자 리더보드 조회에 성공하였습니다.'),
68+
).as('emptyUserLeaderboardAPI');
69+
70+
cy.intercept(
71+
'GET',
72+
'**/api/leaderboard/post*',
73+
BaseSuccess({ posts: [] }, '게시물 리더보드 조회에 성공하였습니다.'),
74+
).as('emptyPostLeaderboardAPI');
75+
76+
cy.reload();
77+
78+
cy.contains('리더보드 데이터가 없습니다').should('be.visible');
79+
cy.contains('현재 설정된 조건에 맞는 사용자 데이터가 없습니다').should('be.visible');
80+
81+
cy.get('select').first().select('게시글 기준');
82+
cy.contains('현재 설정된 조건에 맞는 게시물 데이터가 없습니다').should('be.visible');
83+
});
84+
});

cypress/e2e/login.cy.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
describe('로그인 페이지', () => {
2+
beforeEach(() => cy.visit('/'));
3+
4+
it('페이지가 정상적으로 로드되어야 한다', () => {
5+
cy.waitForPageLoad();
6+
cy.url().should('include', '/');
7+
});
8+
9+
it('로그인 폼이 존재해야 한다', () => {
10+
cy.get('form').should('be.visible');
11+
cy.get(
12+
'input[name*="accessToken"], input[name*="access"], input[placeholder*="Access"]',
13+
).should('be.visible');
14+
cy.get(
15+
'input[name*="refreshToken"], input[name*="refresh"], input[placeholder*="Refresh"]',
16+
).should('be.visible');
17+
cy.get('button[type="submit"], button:contains("로그인")').should('be.visible');
18+
});
19+
20+
it('유효한 토큰으로 로그인할 수 있어야 한다', () => {
21+
cy.get('input[name*="accessToken"], input[name*="access"], input[placeholder*="Access"]')
22+
.first()
23+
.type('valid_access_token');
24+
cy.get('input[name*="refreshToken"], input[name*="refresh"], input[placeholder*="Refresh"]')
25+
.first()
26+
.type('valid_refresh_token');
27+
28+
cy.get('button[type="submit"], button:contains("로그인")').first().click();
29+
30+
cy.url().should('include', '/main');
31+
cy.waitForPageLoad();
32+
});
33+
34+
it('유효하지 않은 토큰으로 로그인 시 에러를 표시해야 한다', () => {
35+
cy.get('input[name*="accessToken"], input[name*="access"], input[placeholder*="Access"]')
36+
.first()
37+
.type('invalid_token');
38+
cy.get('input[name*="refreshToken"], input[name*="refresh"], input[placeholder*="Refresh"]')
39+
.first()
40+
.type('invalid_token');
41+
42+
cy.get('button[type="submit"], button:contains("로그인")').first().click();
43+
44+
cy.url().should('eq', Cypress.config().baseUrl + '/');
45+
});
46+
47+
it('샘플 로그인 버튼이 동작해야 한다', () => {
48+
cy.contains('체험 계정 로그인').should('be.visible').click();
49+
cy.url().should('include', '/main');
50+
cy.waitForPageLoad();
51+
});
52+
});

cypress/e2e/main.cy.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { BaseSuccess } from '../support';
2+
3+
describe('메인 페이지', () => {
4+
beforeEach(() => {
5+
cy.setAuthCookies();
6+
cy.visit('/main');
7+
});
8+
9+
it('페이지가 정상적으로 로드되어야 한다', () => {
10+
cy.waitForPageLoad();
11+
cy.url().should('include', '/main');
12+
});
13+
14+
it('대시보드 통계 정보가 표시되어야 한다', () => {
15+
cy.contains('전체 조회수').should('be.visible');
16+
cy.contains('전체 좋아요 수').should('be.visible');
17+
cy.contains('총 게시글 수').should('be.visible');
18+
19+
cy.contains('2,500').should('be.visible');
20+
cy.contains('350').should('be.visible');
21+
cy.contains('15').should('be.visible');
22+
});
23+
24+
it('게시물 목록이 표시되어야 한다', () => {
25+
cy.contains('테스트 게시물 1').should('be.visible');
26+
cy.contains('테스트 게시물 2').should('be.visible');
27+
28+
cy.get('section').should('contain.text', '150');
29+
cy.get('section').should('contain.text', '25');
30+
cy.get('section').should('contain.text', '200');
31+
cy.get('section').should('contain.text', '35');
32+
});
33+
34+
it('정렬 및 필터 기능이 동작해야 한다', () => {
35+
cy.get('button').should('exist');
36+
37+
cy.get('input[type="checkbox"]').should('exist');
38+
cy.contains('오름차순').should('be.visible');
39+
40+
cy.contains('새로고침').should('be.visible');
41+
cy.contains('새로고침').should('be.disabled');
42+
});
43+
44+
it('마지막 업데이트 시간이 표시되어야 한다', () => {
45+
cy.contains('마지막 업데이트').should('exist');
46+
47+
cy.get('body').should('contain.text', '2025');
48+
});
49+
50+
it('로그아웃 기능이 동작해야 한다', () => {
51+
cy.get('#profile').click();
52+
cy.contains('로그아웃').should('be.visible');
53+
cy.contains('로그아웃').click();
54+
cy.url().should('include', '/');
55+
});
56+
57+
it('빈 데이터 상태를 올바르게 처리해야 한다', () => {
58+
cy.intercept(
59+
'GET',
60+
'**/api/posts*',
61+
BaseSuccess({ nextCursor: null, posts: [] }, '게시물 목록 조회에 성공하였습니다.'),
62+
).as('emptyPostsAPI');
63+
64+
cy.reload();
65+
66+
cy.contains('게시물이 없습니다').should('be.visible');
67+
cy.contains('아직 작성된 게시물이 없습니다. 첫 번째 게시물을 작성해보세요!').should(
68+
'be.visible',
69+
);
70+
cy.contains('📝').should('be.visible');
71+
});
72+
});

cypress/support/base.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const BaseSuccess = <T>(data: T, message: string = '성공적으로 처리되었습니다.') => ({
2+
statusCode: 200,
3+
body: {
4+
success: true,
5+
message,
6+
data,
7+
error: null,
8+
},
9+
});
10+
11+
export const BaseError = (statusCode: number, message: string) => ({
12+
statusCode,
13+
body: {
14+
success: false,
15+
message,
16+
data: null,
17+
error: {
18+
name: 'ServerError',
19+
message,
20+
},
21+
},
22+
});

cypress/support/commands.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN } from './mock';
2+
3+
const DEFAULT_OPTION = {
4+
httpOnly: true,
5+
secure: true,
6+
sameSite: 'strict',
7+
path: '/',
8+
} as const;
9+
10+
Cypress.Commands.add('setAuthCookies', () => {
11+
cy.setCookie('access_token', MOCK_ACCESS_TOKEN, DEFAULT_OPTION);
12+
cy.setCookie('refresh_token', MOCK_REFRESH_TOKEN, DEFAULT_OPTION);
13+
cy.wait(100);
14+
});
15+
16+
Cypress.Commands.add('clearAuthCookies', () => {
17+
cy.clearCookie('access_token');
18+
cy.clearCookie('refresh_token');
19+
});
20+
21+
Cypress.Commands.add('waitForPageLoad', () => {
22+
cy.get('body').should('be.visible');
23+
cy.window().should('have.property', 'document');
24+
});

cypress/support/e2e.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { BaseError, BaseSuccess } from './base';
2+
import {
3+
notificationsResponseData,
4+
postLeaderboardResponseData,
5+
postsFirstData,
6+
postsGraphData,
7+
postsSecondData,
8+
postsStatsResponseData,
9+
totalStatsResponseData,
10+
userLeaderboardResponseData,
11+
userResponseData,
12+
} from './mock';
13+
import './commands';
14+
15+
beforeEach(() => {
16+
cy.intercept('POST', '**/api/login', (req) => {
17+
const body = req.body;
18+
if (body.accessToken === 'invalid_token' || body.refreshToken === 'invalid_token') {
19+
req.reply(BaseError(401, '유효하지 않은 토큰입니다.'));
20+
} else {
21+
req.reply(BaseSuccess(userResponseData, '로그인에 성공하였습니다.'));
22+
}
23+
}).as('loginAPI');
24+
25+
cy.intercept(
26+
'POST',
27+
'**/api/login-sample',
28+
BaseSuccess(userResponseData, '샘플 로그인에 성공하였습니다.'),
29+
).as('sampleLoginAPI');
30+
31+
cy.intercept(
32+
'GET',
33+
'**/api/me',
34+
BaseSuccess(userResponseData, '사용자 정보 조회에 성공하였습니다.'),
35+
).as('meAPI');
36+
37+
cy.intercept('GET', '**/api/posts*', (req) => {
38+
const url = new URL(req.url);
39+
const cursor = url.searchParams.get('cursor');
40+
41+
req.reply(
42+
BaseSuccess(!cursor ? postsFirstData : postsSecondData, '게시물 목록 조회에 성공하였습니다.'),
43+
);
44+
}).as('postsAPI');
45+
46+
cy.intercept(
47+
'GET',
48+
'**/api/posts-stats',
49+
BaseSuccess(postsStatsResponseData, '게시물 통계 조회에 성공하였습니다.'),
50+
).as('postsStatsAPI');
51+
52+
cy.intercept(
53+
'GET',
54+
'**/api/leaderboard/user*',
55+
BaseSuccess(userLeaderboardResponseData, '사용자 리더보드 조회에 성공하였습니다.'),
56+
).as('userLeaderboardAPI');
57+
58+
cy.intercept(
59+
'GET',
60+
'**/api/leaderboard/post*',
61+
BaseSuccess(postLeaderboardResponseData, '게시물 리더보드 조회에 성공하였습니다.'),
62+
).as('postLeaderboardAPI');
63+
64+
cy.intercept(
65+
'GET',
66+
'**/api/total-stats*',
67+
BaseSuccess(totalStatsResponseData, '전체 통계 조회에 성공하였습니다.'),
68+
).as('totalStatsAPI');
69+
70+
cy.intercept(
71+
'GET',
72+
'**/api/notis',
73+
BaseSuccess(notificationsResponseData, '공지사항 조회에 성공하였습니다.'),
74+
).as('notisAPI');
75+
76+
cy.intercept('POST', '**/api/logout', BaseSuccess({}, '성공적으로 로그아웃되었습니다.')).as(
77+
'logoutAPI',
78+
);
79+
80+
cy.intercept(
81+
'GET',
82+
'**/api/post/**',
83+
BaseSuccess(postsGraphData, '게시물 상세 정보 조회에 성공하였습니다.'),
84+
).as('postDetailAPI');
85+
});
86+
87+
/* eslint-disable @typescript-eslint/no-namespace */
88+
declare global {
89+
namespace Cypress {
90+
interface Chainable {
91+
// 인증 토큰을 쿠키에 설정하여 로그인 상태를 모킹합니다.
92+
setAuthCookies(): Chainable<void>;
93+
94+
// 인증 토큰을 쿠키에서 제거합니다.
95+
clearAuthCookies(): Chainable<void>;
96+
97+
// 페이지 로드를 기다립니다.
98+
waitForPageLoad(): Chainable<void>;
99+
}
100+
}
101+
}
102+
/* eslint-enable @typescript-eslint/no-namespace */

cypress/support/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './base';
2+
export * from './mock';
3+
export * from './commands';
4+
export * from './e2e';

0 commit comments

Comments
 (0)