E2E 테스트에선 브라우저를 사용할 수 있기 때문 에 실제 애플리케이션에 가까운 테스트가 가능하다
브라우저 고유의 API를 사용하는 상황 또는 화면을 이동하며 테스트해야하는 상황에 잘맞음
E2E 테스트 프레임워크로 테스트할 때 다음 상황에 구분하지 않고 E2E 테스트라 한다.
데이터베이스 및 하위 시스템과 연동된 E2E 테스트
E2E 테스트는 무엇을 테스트할지 목적을 명확히 세우는 것이 가장 중요함
E2E 테스트에서 DB 서버 또는 외부 저장소 서비스를 포함한 전체 구조에서 얼마나 실제와 유사한 상황을 재현할 것인지가 중요한 기준점이 됨
어떤 관점에서 어떤 선택을 내려야 할지 상황별로 파악해보자.
브라우저 고유 기능과 연동한 UI 테스트 (피처 테스트)
웹 애플리케이션은 브라우저 고유 기능을 사용함
특정 상황에선jsdom
에서 제데로된 테스트를 할 수 없다.
Jest에서 목 객체를 만들어 테스트를 작성할 수도 있지만 상황에 따라 브라우저로 실제 상황과 유사하게 테스트하고 싶다면 UI테스트를 하자.
UI 테스트는 브라우저 고유 기능으로 인터렉션할 수 있으면 충분
API 서버나 다른 하위 시스템은 목 서버로 만들어 E2E 테스트 프레임워크에서 연동된 기능을 검증하면 됨
데이터베이스 및 서브 시스템과 연동한 E2E 테스트
일반적으로 웹 애플리케이션은 DB 서버나 외부 시스템과 연동하여 다음과 같은 기능을 제공한다.
DB 서버와 연동하여 데이터를 불러오거나 저장
외부 저장소 서비스와 연동하여 이미지 등을 업로드
이를 최대한 실제와 유사하게 재현해 검증하는 테스트를 E2E 테스트라 한다.
E2E 테스트 프레임워크는 UI 자동화 기능 으로 실제 애플리케이션을 브라우저 너머에서 조작 한다.
표현 계층, 응용 계층, 영속 계층을 연동하여 검증하므로 실제 상황과 유사성이 높은 테스트로 자리매김함
많은 시스템과 연동하기 때문에 실행 시간이 길고, 불안정 하다 (연동한 시스템이 문제가 있으면 실패)
Doker Compose
docker-compose.e2e.yaml
도커 컴포즈로 여러 도커 컨테이너를 실행하며, 컨테이너 간 통신으로 시스템을 연동해 테스트
UI를 조작하면 영속 계층에 의도한 내용이 저장되고, 그 내용이 화면에 반영되는지를 검증
E2E 테스트에 도커 컴포즈를 도입하면 테스트 환경을 실행하고 종료하는 것이 편리해짐
CI에서 하나의 작업으로 실행할 수 있기 때문에 개발 워크플로에 포함시켜 쉽게 자동화 가능
Playwright
설치 및 설정
Copy $ npm init playwright@latest
Copy # 타입스크립트와 자바스크립트 중 타입스크립트를 선택
? Do you want to use Typescript or Javascript? - Typescript
# 테스트 파일을 저장할 폴더를 설정. e2e라는 이름의 폴더를 사용
? Where to put your end-to-end tests? - e2e
# 깃허브 액션의 워크플로를 추가할지 말지 선택. - NO
? Add a GitHub Actions workflow? (y/N) - false
# 플레이라이트 브라우저를 설치
? Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) - true
설치가 완료되면 package.json에 필요한 모듈이 추가되고, 설정 파일 양식과 샘플 테스트 코드가 생성됨
처음 시작하는 E2E Test
브라우저 자동화 테스트는 테스트마다 브라우저를 열어 지정된 URL로 접속하는것으로 시작 됨
수동으로 브라우저를 조작하면서 애플리케이션 기능을 검증하는 것을 코드로 대체해 테스트를 자동화 가능
Copy import { test, expect } from '@playwright/test';
test("has title", async ({ page }) => {
await page.goto("https://playwright.dev/");
// 페이지 제목에 "Playwright"가 포함됐는지 검증한다.
await expect(page).toHaveTitle(/Playwright/);
});
test("get started link", async ({ page }) => {
await page.goto("https://playwright.dev/");
// "Get started"라는 접근 가능한 이름을 가진 링크를 취득하고, 링크를 클릭한다.
await page.getByRole("link", { name: "Get started" }).click();
// 페이지 URL에 "intro"가 포함됐는지 검증한다.
await expect(page).toHaveURL(/.*intro/);
});
로케이터
플레이라이트의 핵심 API
1.27.0 버전에는 테스팅 라이브러리로부터 영향을 받은 접근성 기반 로케이터가 추가됨
플레이라이트도 접근성 기반 로케이터를 우선적으로 사용하는 것을 권장한다
테스팅 라이브러리와 다른 점은 대기 시간이 필요한지에 따라 findByRole
등을 구분해서 사용하지 않아도 된다.
인터렉션은 비동기 함수이기 때문에 await
로 인터렉션이 완료될 때까지 기다린 후 다음 인터렉션이 실행하는 방식으로 작동됨
Copy await page.getByLabel("User Name").fill("John");
await page.getByLabel("Password").fill("secret-password");
await page.getByRole("button", { name: "Sign in" }).click()
단언문
단언문은 명시적으로 expect
를 import
해서 작성함
vscode 에디터에선 인수에 로케이터를 할당하면 해당 요소 검증에 알맞은 매처를 추천함
Jest와 마찬가지로 not
을 사용해 진릿값을 반전시킬 수 있다.
expect
인수에는 페이지를 할당할 수도 있음
페이지를 인수로 넘기면 에디터가 페이지 검증용 매처를 추천해줌
Copy import { expect } from '@playwright/test';
test("Locator를 사용한 단언문 작성법", async ({ page }) => {
// 특정 문자열을 가진 요소를 취득해서 화면에 보이는 상태인지 검증
await expect(page.getByText("Welcome John!")).toBeVisible();
// 체크박스를 취득해 체크됐는지 검증
await expect(page.getByRole("checkbox")).toBeChecked();
// not으로 진릿값 반전
await expect(page.getByRole("heading")).not.toContainText("some text");
})
Copy import { expect } from '@playwright/test';
test("페이지를 사용한 단언문 작성법", async ({ page }) => {
// 페이지 URL에 "intro"가 포함됐는지 검증
await expect(page).toHaveURL(/.*intro/);
// 페이지 제목에 "Playwright"가 포함됐는지 검증
await expect(page).toHaveTitle(/Playwright/);
})
테스트할 애플리케이션 개요
Copy $ npm i
$ brew install mino/stable/mc // MinIO Client 설치
$ docker compose up -d // 도커 컴포즈로 여러 컨테이너 실행하기
$ sh create-image-bucket.sh // 도커 컴포즈로 실행한 MinIO 서버에 버킷을 생성
$ npm run prisma:migrate // DB를 마이그레이션해서 테스트용 초기 데이터 주입
$ npm run dev // next.js 개발서버 실행 (docker compose up -d를 실행 한후 해야함)
Next.js
모든 페이지를 서버 사이드 렌더링으로 렌더링하며 인증된 요청인지 검사한다.
만약 로그인하지 않은 상황이면 로그인 화면으로 리다이렉트 시킨다.
Next.js는 레디스 서버와 연동하여 세션으로부터 사용자 정보를 취득한다.
Prisma
관게형 데이터베이스는 PostgreSQL 사용
Next.js 서버는 객체 관계 매핑(object-relational mapping, ORM) 도구로 프리즈마를 사용
프리즈마는 타입스크립트 호환성이 좋아서 인기가 많다.
ex: 이너 조인한 테이블의 응답을 타입 추론으로 획득 가능
S3 Client
외부 파일 저장소 서비스로 AWS S3를 사용
로컬 환경에선 실제 버킷을 사용하지 않고 AWS S3 API와 호환이 가능한 MinIO 를 사용
기사의 메인 및 프로필 이미지를 저장하는 공간으로 활용됨
개발 환경에서 E2E 테스트 설정
개발환경에서 테스트 실행하려면 빌드된 Next.js를 실행해야함
Copy $ npm run build && npm start
E2E 테스트를 실행하기 전 DB의 테스트 데이터를 초기화하자. (이후 테스트의 영향을 미치기 때문)
Copy $ npm run prisma:reset
E2E 테스트 실행
초기 설정을 사용하면 헤드리스 모드로 E2E 테스트를 실행하기 때문에 브라우저는 나타나지 않음
Copy $ npx playwright test
$ npx playwright test Login.spec.ts // 특정 테스트 파일만 테스트 실행
테스트 결과를 리포트로 생성하고 싶다면 npx playwright show-report
를 실행
생성된 리포트는 http://localhost:9223/
에서 확인 가능
플레이라이트 검사 도구를 활용한 디버깅
E2E 테스트를 작성하다보면 기대한것과 다르게 테스트가 통과되지 않을 떄가 있음
이럴 땐 플레이라이트 검사 도구 로 원인을 파악해야 한다.
테스트를 실행하는 커맨드에 --debug
옵션을 붙이면 headed
모드로 테스트가 시작됨
브라우저를 열어서 육안으로 자동화된 UI 테스트를 확인할 수 있는 모드
검사 도구를 사용하면 실행 중인 테스트 코드를 보면서 UI가 어떻게 조작되는지 확인 가능
좌측 상단에 녹색 삼각형 재생 아이콘을 클릭하면 UI 테스트 시작됨
재생 아이콘 우측에 녹색 화살표 형태의 스텝 오버 아이콘을 클릭하면 한 줄 씩 테스트 코드가 실행됨
Copy $ npx playwright test Login.spec.ts --debug
도커 컴포즈를 사용하는 E2E 테스트
다른 테스트와 다르게 초기에 컨테이너를 빌드하는 시간이 필요
Copy $ npm run docker:e2e:build && npm run docker:e2e:ci
로그인 기능 E2E 테스트
로그인 여부에 따라 애플리케이션이 기대한 대로 작동하는지 검증
이를 검증하려면 로그인한 후에 특정 작업을 수행하는 인터렉션을 자주 사용해야 한다.
로그인 상태와 연관된 기능 테스트를 위해 반복적인 작업을 공통화하는것이 중요
등록 완료된 사용자로 로그인하기
Copy // e2e/utils.ts
export async function login({
page,
userName = "JPub",
}: {
page: Page;
userName?: UserName;
}) {
const user = getUser(userName);
await page.getByRole("textbox", { name: "메일주소" }).fill(user.email);
await page.getByRole("textbox", { name: "비밀번호" }).fill(user.password);
await page.getByRole("button", { name: "로그인" }).click();
}
export function url(path: string) {
return `http://localhost:3000${path}`;
}
로그인 상태에서 로그아웃
로그아웃 인터렉션도 동일하게 함수로 만들어 공통화
Copy // e2e/utils.ts
export async function logout({
page,
userName = "JPub",
}: {
page: Page;
userName?: UserName;
}) {
const user = getUser(userName);
const loginUser = page.locator("[aria-label='로그인한 사용자']").getByText(user.name)
await loginUser.hover(); // 마우스를 올려 로그아웃 버튼이 나타나게 함
await page.getByText("로그아웃").click()
}
로그인 상태가 아니면 로그인 화면으로 리다이렉트 시키기
미로그인 사용자가 해당 페이지에 접근하면 로그인 화면으로 리다이렉트시켜서 로그인을 요청하는 플로우
이 작업도 대부분 테스트에서 필요한것이라 함수로 공통화
로그인 상태가 아니면 접근할 수 없는 페이지에는 assertUnauthorizedRedirect
함수로 미로그인 사용자가 리다이렉트 됐는지 검증
Copy export async function assertUnauthorizedRedirect({
page,
path
}: {
page: Page;
path: string;
}) {
// 지정된 페이지에 접근
await page.goto(url(path));
// 리다이렉트될 때까지 기다림
await page.waitForURL(url("/login"));
// 로그인 페이지로 이동했는지 확인
await expect(page).toHaveTitle("로그인 | Tech Posts");
}
Copy test("로그인 상태가 아니면 로그인화면으로 리다이렉트된다.", async ({ page }) => {
const path = "/my/posts"; // 접근할 URL 경로
await assertUnauthorizedredirect({ page, path })
})
로그인 후 리다이렉트 이전화면으로 돌아가기
로그인 후에 리다이렉트 전 페이지로 돌아가는지 검증
Copy if (err instanceof UnauthroizedError) {
session.redirectUrl = ctx.resolvedUrl;
return { redirect: { permanent: false, destination: "/login" } };
}
Copy test.describe("로그인 페이지", () => {
const path = "/login";
test("로그인 성공 시 리다이렉트 이전 페이지로 돌아간다", async ({ page }) => {
await page.goto(url("/my/posts"));
await expect(page).toHaveURL(url(path)); //로그인 화면으로 리다이렉트됨
await login({ page }); // 로그인 인터렉션을 실행하는 유팉 함수
await expect(page).toHaveURL(url("/my/posts"));
})
})
프로필 기능 E2E 테스트
UI를 조작해서 프로필 갱신 API 요청을 발생시킴
API Routes가 작동하고 DB 서버에 값이 저장됨
getServerSideProps로 로그인한 사용자 정보 취득하기
서버 사이드 렌더링을 위한 데이터 취득 함수인 getServerSideProps
는 로그인 상태를 검사하는 고차함수(withLogin
) 에 래핑되어있음
인수인 user
에는 로그인한 사용자의 정보가 저장됐는데, 해당 로그인 정보를 기반으로 페이지의 데이터를 요청하거나 프로필 정보를 취득한다.
Copy Page.getPageTitle = PageTitle(
({ data }) => `${data?.authorName}님의 프로필 편집`
);
// 로그인 상태 확인을 포함한 getServerSideProps
export const getServerSideProps = withLogin<Props>(async ({ user }) => {
return {
// 프리즈마 클라이언트를 래핑한 함수를 통해 데이터베이스에서 데이터를 취득
profile: await getMyProfileEdit({ id: user.id }),
authorName: user.name // 제목에 사용할 유저명을 Props에 포함시킴
}
})
프로필 정보를 갱신하는 API Routes
Next.js는 API Routes라는 웹 API 구현 기능을 제공함
UI를 조작해 비동기로 데이터를 취득하거나 갱신하는 요청을 받았을 때 서버 프로세스에서 작업을 처리하여 JSON 같은 형식으로 API 응답을 반환한다.
"대부분 코드가 프리즈마 의존성있는 코드라 일단 스킵"
Copy import { expect, test } from '@playwright/test';
import { UserName } from '../prisma/fixtures/user';
import { login, url } from './util';
test.describe("프로필 편집 페이지", () => {
const path = "/my/profile/edit";
const userName: UserName = "User-MyProfileEdit";
const newName = "NewName";
test("프로필을 편집하면 프로필에 반영된다.", async ({ page }) => {
await page.goto(url(path));
await login({ page, userName });
// 여서부터 프로필 편집 화면
await expect(page).toHaveURL(url(path));
await expect(page).toHaveTitle(`${userName}님의 프로필 편집`);
await page.getByRole("textbox", { name: "사용자명" }).fill(newName);
await page.getByRole("button", { name: "프로필 변경하기" }).click();
await page.waitForURL(url("/my/posts"));
// 페이지 제목에 방금 입력한 이름이 포함됨
await expect(page).toHaveTitle(`${newName}님의 기사 목록`);
await expect(page.getByRole("region", { name: 프로필" })).toContainText(newName);
await expect(page.locator("[aria-label='로그인한 사용자']")).toContainText(newName);
})
})
Like 기능 E2E 테스트
검증
다른 사람이 작성한 기사에는 Like를 누를 수 있어야 한다.
자신의 기사에는 Like를 누를 수 없어야 한다.
다른 사람이 작성한 기사에는 Like 버튼을 누를 수 있어야 한다.
Copy test("다른 사람의 기사에는 Like 할 수 있다.", async ({ page }) => {
await page.goto(url("/login"));
await login({ page, userName: "Jpub" });
await expect(page).toHaveURL(url("/"));
// 여기서부터 기사 목록 페이지
await page.goto(url("/posts/10"));
const buttonLike = page.getByRole("button", { name: "Like" });
const buttonText = page.getByTestId("likeStatus");
// like 버튼이 활성화되고, 현재 Like 수는 0
await expect(buttonLike).toBeEnabled();
await expect(buttonLike).toHaveText("0");
await expect(buttonText).toHaveText("Like");
await buttonLike.click();
// like를 클릭하면 카운트가 1증가하고 이미 like 누른 상태가 된다.
await expect(buttonLike).toHaveText("1");
await expect(buttonText).toHaveText("Liked");
})
본인이 작성한 기사에는 like 버튼을 누를 수 없다.
Copy test("본인이 작성한 기사에는 Like 할 수 없다.", async ({ page }) => {
await page.goto(url("/login"));
await login({ page, userName: "Jpub" });
await expect(page).toHaveURL(url("/"));
// 여기서부터 기사 목록 페이지
await page.goto(url("/posts/90"));
const buttonLike = page.getByRole("button", { name: "Like" });
const buttonText = page.getByTestId("likeStatus");
// like 버튼이 비활성화되고, Like 문자도 사라짐
await expect(buttonLike).toBeDisabled();
await expect(buttonText).not.toHavetext("Like");
})
신규 작성 페이지 E2E 테스트
이른바 CRUD라 불리는 기능의 페이지 테스트
CRUD 기능 테스트는 다른 테스트에 영향을 미치지 않는지 세심히 살펴봐야 한다.
기본적으로 Publish 기능 테스트는 새로운 기사를 작성한 후 해당 기사를 대상으로 CRUD해야 한다.
신규 작성 페이지에 접근해 컨텐츠를 입력하는 함수
투고 기능의 E2E 테스트에는 새 기사를 작성하는 작업을 여러 번 실행해야 한다.
컨텐츠 내용은 신경쓰지 말고 필수 입력 항목만 채우는 인터렉션 함수를 만든다.
다른 테스트에 영향을 미치지 않도록 재사용 가능한 기사 작성 함수를 만들어보자.
Copy //e2e/postUtils.ts
export async function gotoAndFillPostContents({
page,
title,
userName,
}: {
page: Page;
title: string;
userName: UserName;
}) {
await page.goto(url("/login"));
await login({ page, userName });
await expect(page).toHaveURL(url("/"));
await page.goto(url("/my/posts/create"));
await page.setInputFiles("data-testid=file", [
"public/__mocks__/images/img01.jpg",
]);
await page waitForLoadState("networkidle", { timeout: 30000 });
await page.getbyRole("textbox", { name: "제목" }).fill(title);
}
신규 기사를 비공개 상태로 저장하는 함수
신규 기사를 비공개 상태로 저장하는 작업도 여러 번 실행되는 인터렉션
Copy //e2e/postUtils.ts
export async function gotoAndCreatePostAsDraft({
page,
title,
userName,
}: {
page: Page;
title: string;
userName: UserName;
}) {
await gotoAndFillPostContents({ page, title, userName });
await page.getByRole("button", { name: "비공개 상태로 저장" }).click();
await page.waitForNavigation();
await expect(page).toHaveTitle(title);
}
신규 기사를 공개하는 함수
Copy //e2e/postUtils.ts
export async function gotoAndCreatePostAsPublish({
page,
title,
userName,
}: {
page: Page;
title: string;
userName: UserName;
}) {
await gotoAndFillPostContents({ page, title, userName });
await page.getByText("공개 여부").click();
await page.getByRole("button", { name: "공개하기" }).click();
await page.getByRole("button", { name: "" }).click();
await page.waitForNavigation();
await expect(page).toHaveTitle(title);
}
재사용되는 유틸 함수들을 활용하여 E2E 테스트 작성하기
expect
함수를 사용하는 단언문도 앞서 만든 함수들에 포함됐으므로 검증이 간단해진다.
Copy //e2e/MyPostscreate.spec.ts
import { test } from '@playwright/test';
import { UserName } from '../prisma/fixtures/user';
import {
gotoAndCreatePostAsDraft,
gotoAndCreatePostAsPublish
} from './postUtil';
test.describe("신규 기사 페이지", () => {
const path = "/my/posts/create";
const userName: UserName = "JPub";
test("신규 기사를 비공개 상태로 저장할 수 있다.", async ({ page }) => {
const title = "비공개 상태로 저장하기 테스트";
await gotoAndCreatePostAsDraft({ page, title, userName });
})
test("신규 기사를 공개할 수 있다.", async ({ page }) => {
const title = "공개하기 테스트";
await gotoAndCreatePostAsPublish({ page, title, userName });
})
})
기사 편집 페이지 E2E 테스트
기사를 편집했을 때 기사 목록에 미치는 영향과 기사를 삭제하는 기능에 대한 E2E 테스트
공통 함수
작성된 기사 페이지로 이동한 뒤 편집 페이지로 이동하는 인터렉션 함수
Copy export async function gotoEditPostPage({
page,
title,
}: {
page: Page;
title: string;
}) {
await page.getByRole("link", { name: "편집하기" }).click();
await page.waitForNavigation();
await expect(page).toHaveTitle(`기사 편집 | ${title}`);
}
검증
비공개 상태로 저장한 기사를 편집해 내용이 갱신되는지 검증
비공개 상태로 저장한 기사 공개 상태로 변경할 수 있는지 검증
공개된 기사를 편집하고 비공개 상태로 변경할 수 있는지 검증
Copy import { expect, test } from "@playwright/test";
import { checkA11y, injectAxe } from "axe-playwright";
import { UserName } from "../prisma/fixtures/user";
import {
gotoAndCreatePostAsDraft,
gotoAndCreatePostAsPublish,
gotoEditPostPage,
} from "./postUtil";
import { assertUnauthorizedRedirect, url } from "./util";
test.describe("기사 편집 페이지", () => {
const path = "/my/posts/1/edit";
const userName: UserName = "JPub";
test("로그인 상태가 아니면 로그인 화면으로 리다이렉트된다", async ({ page }) => {
await assertUnauthorizedRedirect({ page, path });
});
test("비공개 기사를 편집할 수 있다", async ({ page }) => {
const title = "비공개 편집 테스트";
const newTitle = "비공개 편집 테스트 갱신 완료";
await gotoAndCreatePostAsDraft({ page, title, userName });
await gotoEditPostPage({ page, title });
await page.getByRole("textbox", { name: "제목" }).fill(newTitle);
await page.getByRole("button", { name: "비공개 상태로 저장" }).click();
await page.waitForNavigation();
await expect(page).toHaveTitle(newTitle);
});
test("비공개 기사를 공개할 수 있다", async ({ page }) => {
const title = "비공개 기사 공개 테스트";
await gotoAndCreatePostAsDraft({ page, title, userName });
await gotoEditPostPage({ page, title });
await page.getByText("공개 여부").click();
await page.getByRole("button", { name: "공개하기" }).click();
await page.getByRole("button", { name: "네" }).click();
await page.waitForNavigation();
await expect(page).toHaveTitle(title);
});
test("공개된 기사를 비공개할 수 있다", async ({ page }) => {
const title = "기사 비공개 테스트";
await gotoAndCreatePostAsPublish({ page, title, userName });
await gotoEditPostPage({ page, title });
await page.getByText("공개 여부").click();
await page.getByRole("button", { name: "비공개 상태로 저장" }).click();
await page.waitForNavigation();
await expect(page).toHaveTitle(title);
});
test("공개된 기사를 삭제할 수 있다", async ({ page }) => {
const title = "기사 삭제 테스트";
await gotoAndCreatePostAsPublish({ page, title, userName });
await gotoEditPostPage({ page, title });
await page.getByRole("button", { name: "삭제하기" }).click();
await page.getByRole("button", { name: "네" }).click();
await page.waitForNavigation();
await expect(page).toHaveTitle(`${userName}님의 기사 목록`);
});
test("접근성 검증", async ({ page }) => {
await page.goto(url(path));
await injectAxe(page as any);
await checkA11y(page as any);
});
});
기사 목록 페이지 E2E 테스트
신규 기사가 작성되면 기사 목록에 어떤 영향을 미치는지에 대한 E2E 테스트
기사 목록은 메인 페이지와 마이 페이지 두 곳에 존재
비공개 상태로 저장한 기사는 작성자 외 열람이 불가능하므로 메인 페이지에선 노출되지 않아야함
검증
마이 페이지의 기사 목록에 신규 기사가 추가됐는지 검증
비공개 상태로 저장한 기사와 공개 상태로 저장한 기사 모두 목록에 추가돼야함
메인 페이지의 기사 목록에 신규 기사가 추가됐는지 검증
비공개 상태로 저장한 기사는 목록에 안보여야함
Copy import { expect, test } from "@playwright/test";
import { checkA11y, injectAxe } from "axe-playwright";
import { UserName } from "../prisma/fixtures/user";
import {
gotoAndCreatePostAsDraft,
gotoAndCreatePostAsPublish,
} from "./postUtil";
import { assertUnauthorizedRedirect, login, url } from "./util";
/* 코드 10-15
test("로그인 상태가 아니면 로그인 화면으로 리다이렉트된다", async ({ page }) => {
const path = "/my/posts";
await assertUnauthorizedRedirect({ page, path });
});
*/
test.describe("게재된 기사 목록 페이지", () => {
const path = "/my/posts";
const userName: UserName = "JPub";
test("로그인 상태가 아니면 로그인 화면으로 리다이렉트된다", async ({ page }) => {
await assertUnauthorizedRedirect({ page, path });
});
test("자신의 프로필을 열람할 수 있다", async ({ page }) => {
await page.goto(url(path));
await login({ page });
await expect(page).toHaveURL(url(path));
const profile = page.getByRole("region", { name: "프로필" });
await expect(profile).toContainText("JPub");
});
test("신규 기사를 비공개 상태로 저장하면 게재된 기사 목록에 기사가 추가된다", async ({
page,
}) => {
const title = "비공개로 저장된 기사 목록 테스트";
await gotoAndCreatePostAsDraft({ page, title, userName });
await page.goto(url(path));
await expect(page.getByText(title)).toBeVisible();
});
test("신규 기사를 공개 상태로 저장하면 게재된 기사 목록에 기사가 추가된다", async ({
page,
}) => {
const title = "공개 상태로 저장된 기사 목록 테스트";
await gotoAndCreatePostAsPublish({ page, title, userName });
await page.goto(url(path));
await expect(page.getByText(title)).toBeVisible();
});
test("접근성 검증", async ({ page }) => {
await page.goto(url(path));
await injectAxe(page as any);
await checkA11y(page as any);
});
});
불안정한 테스트 대처 방법
흔히 E2E 테스트는 안정적이지 않다고 생각함
불안정한 테스트는 네트워크 지연이나, 메모리 부족에 의한 서버 응답 지연 등 다양한 원인으로 발생함
테스트 실행 순서의 영향으로 의도하지 않은 상태에서 테스트를 시작하여 문제가 발생하기도 함
이런 다양한 원인으로 불안정한 테스트가 발생하는 것은 E2E 테스트에선 피할 수 없는 문제
실행할 때마다 데이터베이스 재설정하기
DB를 사용하는 E2E 테스트는 테스트를 실행하면 데이터가 변경된다.
일관성있는 결과를 얻으려면 테스트 시작 시점의 상태는 항상 동일해야 한다.
테스트를 실행할 때마다 시드 스크립트로 초깃값을 재설정 해야함?
테스트마다 사용자를 새로 만들기
프로필 편집 기능을 테스트한다고 기존 사용자 정보를 변경하면 안됨
테스트에선 각 테스트를 위해 생성한 사용자를 사용해야 한다.
테스트 간 리소스가 경합하지 않도록 주의
CRUD 기능의 E2E 테스트처럼 각 테스트에서 매번 새로운 리소스를 작성하도록 해야함
플레이라이트 테스트는 병렬처리되기 때문에 테스트 실행 순서를 보장할 수 없다.
불안정한 테스트가 발견되면 리소스 경합이 발생하고 있는지 검토해야함
빌드한 애플리케이션 서버로 테스트하기
next.js 애플리케이션에서 개발할 때는 개발 서버에서 디버깅하며 코드를 작성하지만 E2E 테스트는 개발 서버에서 실행하지 않도록 주의 해야 한다.
빌드한 Next.js 애플리케이션은 개발 서버와 다르게 동작하기 때문
또한 개발 서버는 응답 속도도 느려서 불안정한 테스트의 원인이 되기도함
비동기 처리 대기하기
예를들어 이미지 업로드 인터렉션이 있다면, 네트워크 통신이 완료될 때까지 기다려야 한다.
이처럼 시간이 걸리는 작업은 단위 테스트에서 했던것처럼 비동기 처리 응답을 대기 해야 한다.
만약 조작할 요소가 존재하고 문제없이 인터렉션이 할당됐음에도 테스트가 실패한다면 비동기 처리를 제데로 대기하는지 확인해야함
--debug로 테스트 실패 원인 조사하기
불안정한 테스트의 원인을 파악하려면 디버거를 활용해야함
플레이라이트는 실행할 때 --debug
를 붙이면 디버거를 실행할 수 있음
직접 디버거에서 한 줄 씩 작동을 확인하며 실패 원인을 파악해보자.
CI 환경과 CPU 코어 수 맞추기
로컬에서 테스트를 실행하는 경우 모두 통과하지만 CI 환경에서는 실패하기도 한다.
이 경우 로컬 기기의 CPU 코어 수와 CI 코어수가 동일한지 체크 해야 한다.
플레이라이트나 제스트는 코어 수를 명시적으로 지정하지 않으면 실행 환경에서 실행 가능한 만큼 테스트 스위트를 병렬 처리한다.
즉, 병렬처리되는 숫자는 실행 환경의 CPU 코어 수 떄문에 변동됨
CPU 코어 수가 변도되지 않도록 고정하는 설정을 추가한다. (테스트 러너에 지정할 수 있음)
CI의 코어 수에 맞춰 설정한 후, 로컬에서도 모든 테스트가 통과되면 문제 해결
만약 해결되지 않은 경우 대기 시간의 상한을 늘려보자.
전체적으로 보면 CI에서 테스트가 실패해 계속 재시도 하는 것보다 시간을 단축시킬 수 있다.
테스트 범위 최소화
상황에 따라 E2E 테스트로 검증하는 것이 적절한지 살펴봐야 한다.
테스트 피라미드 상층부에 가까울 수록 실제 상황과 유사한 테스트가 가능 하지만, 반대로 불안정성이 증가하고 실행시간도 길어짐
E2E 테스트를 더 넓은 범위의 통합 테스트로 대체가능하면 더 적은 비용으로 안정적인 테스트를 할 수 있다.
검증 내용에 맞는 최적의 테스트 범위를 찾아야 불안정한 테스트가 생길 가능성이 낮아진다.
Last updated 10 months ago