Page cover image

E2E 테스트

  • E2E 테스트에선 브라우저를 사용할 수 있기 때문에 실제 애플리케이션에 가까운 테스트가 가능하다

    • 브라우저 고유의 API를 사용하는 상황 또는 화면을 이동하며 테스트해야하는 상황에 잘맞음

  • E2E 테스트 프레임워크로 테스트할 때 다음 상황에 구분하지 않고 E2E 테스트라 한다.

    • 브라우저 고유 기능과 연동된 UI 테스트

    • 데이터베이스 및 하위 시스템과 연동된 E2E 테스트

  • E2E 테스트는 무엇을 테스트할지 목적을 명확히 세우는 것이 가장 중요함

  • E2E 테스트에서 DB 서버 또는 외부 저장소 서비스를 포함한 전체 구조에서 얼마나 실제와 유사한 상황을 재현할 것인지가 중요한 기준점이 됨

    • 어떤 관점에서 어떤 선택을 내려야 할지 상황별로 파악해보자.

브라우저 고유 기능과 연동한 UI 테스트 (피처 테스트)

  • 웹 애플리케이션은 브라우저 고유 기능을 사용함

  • 특정 상황에선jsdom 에서 제데로된 테스트를 할 수 없다.

    • 화면 간 이동

    • 화면 크기를 측정해서 실행된는 로직

    • CSS 미디어 쿼리를 사용한 반응형 처리

    • 스크롤 위치에 따른 이벤트 발생

    • 쿠키나 로컬 스토리지 등에 데이터 저장

  • Jest에서 목 객체를 만들어 테스트를 작성할 수도 있지만 상황에 따라 브라우저로 실제 상황과 유사하게 테스트하고 싶다면 UI테스트를 하자.

    • UI 테스트는 브라우저 고유 기능으로 인터렉션할 수 있으면 충분

    • API 서버나 다른 하위 시스템은 목 서버로 만들어 E2E 테스트 프레임워크에서 연동된 기능을 검증하면 됨

    • 이것을 피처 테스트라고도 부름

데이터베이스 및 서브 시스템과 연동한 E2E 테스트

  • 일반적으로 웹 애플리케이션은 DB 서버나 외부 시스템과 연동하여 다음과 같은 기능을 제공한다.

    • DB 서버와 연동하여 데이터를 불러오거나 저장

    • 외부 저장소 서비스와 연동하여 이미지 등을 업로드

    • 레디스와 연동하여 세션 관리

  • 이를 최대한 실제와 유사하게 재현해 검증하는 테스트를 E2E 테스트라 한다.

  • E2E 테스트 프레임워크는 UI 자동화 기능으로 실제 애플리케이션을 브라우저 너머에서 조작한다.

  • 표현 계층, 응용 계층, 영속 계층을 연동하여 검증하므로 실제 상황과 유사성이 높은 테스트로 자리매김함

  • 많은 시스템과 연동하기 때문에 실행 시간이 길고, 불안정하다 (연동한 시스템이 문제가 있으면 실패)

Doker Compose

docker-compose.e2e.yaml

  • 도커 컴포즈로 여러 도커 컨테이너를 실행하며, 컨테이너 간 통신으로 시스템을 연동해 테스트

    • UI를 조작하면 영속 계층에 의도한 내용이 저장되고, 그 내용이 화면에 반영되는지를 검증

  • E2E 테스트에 도커 컴포즈를 도입하면 테스트 환경을 실행하고 종료하는 것이 편리해짐

  • CI에서 하나의 작업으로 실행할 수 있기 때문에 개발 워크플로에 포함시켜 쉽게 자동화 가능

Playwright

  • 마이크로소프트가 공개한 E2E 프레임워크

  • 크로스 브라우징 지원

  • 다양한 기능 제공

    • 디버깅 테스트

    • 리포터

    • 트레이스 뷰어

    • 테스트 코드 생성기

설치 및 설정

$ npm init playwright@latest
# 타입스크립트와 자바스크립트 중 타입스크립트를 선택
? 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로 접속하는것으로 시작

    • page.goto 에 URL 지정

  • 수동으로 브라우저를 조작하면서 애플리케이션 기능을 검증하는 것을 코드로 대체해 테스트를 자동화 가능

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 로 인터렉션이 완료될 때까지 기다린 후 다음 인터렉션이 실행하는 방식으로 작동됨

await page.getByLabel("User Name").fill("John");
await page.getByLabel("Password").fill("secret-password");
await page.getByRole("button", { name: "Sign in" }).click()

단언문

  • 단언문은 명시적으로 expectimport 해서 작성함

  • vscode 에디터에선 인수에 로케이터를 할당하면 해당 요소 검증에 알맞은 매처를 추천함

  • Jest와 마찬가지로 not 을 사용해 진릿값을 반전시킬 수 있다.

  • expect 인수에는 페이지를 할당할 수도 있음

    • 페이지를 인수로 넘기면 에디터가 페이지 검증용 매처를 추천해줌

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");
})
import { expect } from '@playwright/test';
test("페이지를 사용한 단언문 작성법", async ({ page }) => {
  // 페이지 URL에 "intro"가 포함됐는지 검증
  await expect(page).toHaveURL(/.*intro/);
  // 페이지 제목에 "Playwright"가 포함됐는지 검증
  await expect(page).toHaveTitle(/Playwright/);
})

테스트할 애플리케이션 개요

$ 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를 실행해야함

$ npm run build && npm start
  • E2E 테스트를 실행하기 전 DB의 테스트 데이터를 초기화하자. (이후 테스트의 영향을 미치기 때문)

$ npm run prisma:reset

E2E 테스트 실행

  • 초기 설정을 사용하면 헤드리스 모드로 E2E 테스트를 실행하기 때문에 브라우저는 나타나지 않음

$ npx playwright test
$ npx playwright test Login.spec.ts // 특정 테스트 파일만 테스트 실행
  • 테스트 결과를 리포트로 생성하고 싶다면 npx playwright show-report 를 실행

    • 생성된 리포트는 http://localhost:9223/ 에서 확인 가능

플레이라이트 검사 도구를 활용한 디버깅

  • E2E 테스트를 작성하다보면 기대한것과 다르게 테스트가 통과되지 않을 떄가 있음

  • 이럴 땐 플레이라이트 검사 도구로 원인을 파악해야 한다.

  • 테스트를 실행하는 커맨드에 --debug 옵션을 붙이면 headed 모드로 테스트가 시작됨

    • 브라우저를 열어서 육안으로 자동화된 UI 테스트를 확인할 수 있는 모드

  • 검사 도구를 사용하면 실행 중인 테스트 코드를 보면서 UI가 어떻게 조작되는지 확인 가능

  • 좌측 상단에 녹색 삼각형 재생 아이콘을 클릭하면 UI 테스트 시작됨

  • 재생 아이콘 우측에 녹색 화살표 형태의 스텝 오버 아이콘을 클릭하면 한 줄 씩 테스트 코드가 실행됨

$ npx playwright test Login.spec.ts --debug

도커 컴포즈를 사용하는 E2E 테스트

  • 다른 테스트와 다르게 초기에 컨테이너를 빌드하는 시간이 필요

  • CI용으로 작성한 것

$ npm run docker:e2e:build && npm run docker:e2e:ci

로그인 기능 E2E 테스트

  • 로그인 여부에 따라 애플리케이션이 기대한 대로 작동하는지 검증

  • 이를 검증하려면 로그인한 후에 특정 작업을 수행하는 인터렉션을 자주 사용해야 한다.

  • 로그인 상태와 연관된 기능 테스트를 위해 반복적인 작업을 공통화하는것이 중요

등록 완료된 사용자로 로그인하기

  • 로그인하는 인터렉션을 함수로 만들어 공통화

// 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}`;
}

로그인 상태에서 로그아웃

  • 로그아웃 인터렉션도 동일하게 함수로 만들어 공통화

// 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 함수로 미로그인 사용자가 리다이렉트 됐는지 검증

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");
}
test("로그인 상태가 아니면 로그인화면으로 리다이렉트된다.", async ({ page }) => {
  const path = "/my/posts"; // 접근할 URL 경로
  await assertUnauthorizedredirect({ page, path })
})

로그인 후 리다이렉트 이전화면으로 돌아가기

  • 로그인 후에 리다이렉트 전 페이지로 돌아가는지 검증

if (err instanceof UnauthroizedError) {
  session.redirectUrl = ctx.resolvedUrl;
  return { redirect: { permanent: false, destination: "/login" } };
}
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에는 로그인한 사용자의 정보가 저장됐는데, 해당 로그인 정보를 기반으로 페이지의 데이터를 요청하거나 프로필 정보를 취득한다.

  • user 객체는 세션에 저장된 정보를 불러옴

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 응답을 반환한다.

  • "대부분 코드가 프리즈마 의존성있는 코드라 일단 스킵"

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를 누를 수 없어야 한다.

다른 사람이 작성한 기사에는 Like 버튼을 누를 수 있어야 한다.

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 버튼을 누를 수 없다.

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 테스트에는 새 기사를 작성하는 작업을 여러 번 실행해야 한다.

  • 컨텐츠 내용은 신경쓰지 말고 필수 입력 항목만 채우는 인터렉션 함수를 만든다.

  • 다른 테스트에 영향을 미치지 않도록 재사용 가능한 기사 작성 함수를 만들어보자.

//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);
}

신규 기사를 비공개 상태로 저장하는 함수

  • 신규 기사를 비공개 상태로 저장하는 작업도 여러 번 실행되는 인터렉션

//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);
}

신규 기사를 공개하는 함수

//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 함수를 사용하는 단언문도 앞서 만든 함수들에 포함됐으므로 검증이 간단해진다.

//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 테스트

공통 함수

  • 작성된 기사 페이지로 이동한 뒤 편집 페이지로 이동하는 인터렉션 함수

export async function gotoEditPostPage({
  page,
  title,
}: {
  page: Page;
  title: string;
}) {
  await page.getByRole("link", { name: "편집하기" }).click();
  await page.waitForNavigation();
  await expect(page).toHaveTitle(`기사 편집 | ${title}`);
}

검증

  • 비공개 상태로 저장한 기사를 편집해 내용이 갱신되는지 검증

  • 비공개 상태로 저장한 기사 공개 상태로 변경할 수 있는지 검증

  • 공개된 기사를 편집하고 비공개 상태로 변경할 수 있는지 검증

  • 공개된 기사를 삭제할 수 있는지 검증

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 테스트

  • 기사 목록은 메인 페이지와 마이 페이지 두 곳에 존재

    • 비공개 상태로 저장한 기사는 작성자 외 열람이 불가능하므로 메인 페이지에선 노출되지 않아야함

검증

  • 마이 페이지의 기사 목록에 신규 기사가 추가됐는지 검증

    • 비공개 상태로 저장한 기사와 공개 상태로 저장한 기사 모두 목록에 추가돼야함

  • 메인 페이지의 기사 목록에 신규 기사가 추가됐는지 검증

    • 비공개 상태로 저장한 기사는 목록에 안보여야함

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 테스트처럼 각 테스트에서 매번 새로운 리소스를 작성하도록 해야함

  • 플레이라이트 테스트는 병렬처리되기 때문에 테스트 실행 순서를 보장할 수 없다.

  • 불안정한 테스트가 발견되면 리소스 경합이 발생하고 있는지 검토해야함

리소스 경합이란?

  • 여러 프로세스나 스레드가 동시에 동일한 자원(예: CPU, 메모리, 디스크, 네트워크 등)에 접근하려고 할 때 발생하는 문제를 말한다

빌드한 애플리케이션 서버로 테스트하기

  • next.js 애플리케이션에서 개발할 때는 개발 서버에서 디버깅하며 코드를 작성하지만 E2E 테스트는 개발 서버에서 실행하지 않도록 주의해야 한다.

  • 빌드한 Next.js 애플리케이션은 개발 서버와 다르게 동작하기 때문

  • 또한 개발 서버는 응답 속도도 느려서 불안정한 테스트의 원인이 되기도함

비동기 처리 대기하기

  • 예를들어 이미지 업로드 인터렉션이 있다면, 네트워크 통신이 완료될 때까지 기다려야 한다.

  • 이처럼 시간이 걸리는 작업은 단위 테스트에서 했던것처럼 비동기 처리 응답을 대기해야 한다.

  • 만약 조작할 요소가 존재하고 문제없이 인터렉션이 할당됐음에도 테스트가 실패한다면 비동기 처리를 제데로 대기하는지 확인해야함

--debug로 테스트 실패 원인 조사하기

  • 불안정한 테스트의 원인을 파악하려면 디버거를 활용해야함

  • 플레이라이트는 실행할 때 --debug 를 붙이면 디버거를 실행할 수 있음

  • 직접 디버거에서 한 줄 씩 작동을 확인하며 실패 원인을 파악해보자.

CI 환경과 CPU 코어 수 맞추기

  • 로컬에서 테스트를 실행하는 경우 모두 통과하지만 CI 환경에서는 실패하기도 한다.

  • 이 경우 로컬 기기의 CPU 코어 수와 CI 코어수가 동일한지 체크해야 한다.

  • 플레이라이트나 제스트는 코어 수를 명시적으로 지정하지 않으면 실행 환경에서 실행 가능한 만큼 테스트 스위트를 병렬 처리한다.

    • 즉, 병렬처리되는 숫자는 실행 환경의 CPU 코어 수 떄문에 변동됨

  • CPU 코어 수가 변도되지 않도록 고정하는 설정을 추가한다. (테스트 러너에 지정할 수 있음)

  • CI의 코어 수에 맞춰 설정한 후, 로컬에서도 모든 테스트가 통과되면 문제 해결

  • 만약 해결되지 않은 경우 대기 시간의 상한을 늘려보자.

    • 전체적으로 보면 CI에서 테스트가 실패해 계속 재시도 하는 것보다 시간을 단축시킬 수 있다.

테스트 범위 최소화

  • 상황에 따라 E2E 테스트로 검증하는 것이 적절한지 살펴봐야 한다.

  • 테스트 피라미드 상층부에 가까울 수록 실제 상황과 유사한 테스트가 가능하지만, 반대로 불안정성이 증가하고 실행시간도 길어짐

  • E2E 테스트를 더 넓은 범위의 통합 테스트로 대체가능하면 더 적은 비용으로 안정적인 테스트를 할 수 있다.

  • 검증 내용에 맞는 최적의 테스트 범위를 찾아야 불안정한 테스트가 생길 가능성이 낮아진다.

Last updated