# E2E 테스트

* E2E 테스트에선 **브라우저를 사용할 수 있기 때문**에 실제 애플리케이션에 가까운 테스트가 가능하다
  * 브라우저 고유의 API를 사용하는 상황 또는 화면을 이동하며 테스트해야하는 상황에 잘맞음
* E2E 테스트 프레임워크로 테스트할 때 다음 상황에 구분하지 않고 E2E 테스트라 한다.
  * 브라우저 고유 기능과 연동된 UI 테스트
  * 데이터베이스 및 하위 시스템과 연동된 E2E 테스트
* E2E 테스트는 <mark style="color:purple;">**무엇을 테스트할지 목적을 명확히**</mark> 세우는 것이 가장 중요함
* E2E 테스트에서 DB 서버 또는 외부 저장소 서비스를 포함한 전체 구조에서 얼마나 실제와 유사한 상황을 재현할 것인지가 중요한 기준점이 됨
  * 어떤 관점에서 어떤 선택을 내려야 할지 상황별로 파악해보자.

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

* 웹 애플리케이션은 브라우저 고유 기능을 사용함
* 특정 상황에선`jsdom` 에서 제데로된 테스트를 할 수 없다.
  * 화면 간 이동
  * 화면 크기를 측정해서 실행된는 로직
  * CSS 미디어 쿼리를 사용한 반응형 처리
  * 스크롤 위치에 따른 이벤트 발생
  * 쿠키나 로컬 스토리지 등에 데이터 저장
* Jest에서 목 객체를 만들어 테스트를 작성할 수도 있지만 상황에 따라 브라우저로 실제 상황과 유사하게 테스트하고 싶다면 UI테스트를 하자.
  * <mark style="color:orange;">UI 테스트는 브라우저 고유 기능으로 인터렉션할 수 있으면 충분</mark>
  * <mark style="color:orange;">**API 서버나 다른 하위 시스템은 목 서버로**</mark> 만들어 E2E 테스트 프레임워크에서 연동된 기능을 검증하면 됨
  * 이것을 <mark style="color:orange;">**피처 테스트**</mark>라고도 부름

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

* 일반적으로 웹 애플리케이션은 DB 서버나 외부 시스템과 연동하여 다음과 같은 기능을 제공한다.
  * DB 서버와 연동하여 데이터를 불러오거나 저장
  * 외부 저장소 서비스와 연동하여 이미지 등을 업로드
  * 레디스와 연동하여 세션 관리
* 이를 최대한 실제와 유사하게 재현해 검증하는 테스트를 E2E 테스트라 한다.
* E2E 테스트 프레임워크는 **UI 자동화 기능**으로 실제 애플리케이션을 **브라우저 너머에서 조작**한다.
* 표현 계층, 응용 계층, 영속 계층을 연동하여 검증하므로 실제 상황과 유사성이 높은 테스트로 자리매김함
* 많은 시스템과 연동하기 때문에 <mark style="color:red;">**실행 시간이 길고, 불안정**</mark>하다 (연동한 시스템이 문제가 있으면 실패)

#### Doker Compose

> [docker-compose.e2e.yaml](https://github.com/frontend-testing-book-kr/nextjs/blob/main/docker-compose.e2e.yaml)

* 도커 컴포즈로 여러 도커 컨테이너를 실행하며, 컨테이너 간 통신으로 시스템을 연동해 테스트
  * UI를 조작하면 영속 계층에 의도한 내용이 저장되고, 그 내용이 화면에 반영되는지를 검증
* E2E 테스트에 도커 컴포즈를 도입하면 테스트 환경을 실행하고 종료하는 것이 편리해짐
* CI에서 하나의 작업으로 실행할 수 있기 때문에 개발 워크플로에 포함시켜 쉽게 자동화 가능

## Playwright

* 마이크로소프트가 공개한 E2E 프레임워크
* 크로스 브라우징 지원
* 다양한 기능 제공
  * 디버깅 테스트
  * 리포터
  * 트레이스 뷰어
  * 테스트 코드 생성기

### 설치 및 설정

```bash
$ npm init playwright@latest
```

```bash
# 타입스크립트와 자바스크립트 중 타입스크립트를 선택
? 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

* 브라우저 자동화 테스트는 <mark style="color:blue;">**테스트마다 브라우저를 열어 지정된 URL로 접속하는것으로 시작**</mark>됨
  * `page.goto` 에 URL 지정
* 수동으로 브라우저를 조작하면서 애플리케이션 기능을 검증하는 것을 코드로 대체해 테스트를 자동화 가능

```typescript
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 버전에는 테스팅 라이브러리로부터 영향을 받은 접근성 기반 로케이터가 추가됨
  * 플레이라이트도 접근성 기반 로케이터를 우선적으로 사용하는 것을 권장한다
* 테스팅 라이브러리와 다른 점은 대기 시간이 필요한지에 따라 <mark style="color:red;">**`findByRole`**</mark><mark style="color:red;">**&#x20;**</mark><mark style="color:red;">**등을 구분해서 사용하지 않아도 된다.**</mark>
  * 인터렉션은 비동기 함수이기 때문에 `await` 로 인터렉션이 완료될 때까지 기다린 후 다음 인터렉션이 실행하는 방식으로 작동됨

```typescript
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` 인수에는 페이지를 할당할 수도 있음
  * 페이지를 인수로 넘기면 에디터가 페이지 검증용 매처를 추천해줌

```typescript
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");
})
```

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

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

* [예제 repo](https://github.com/frontend-testing-book-kr/nextjs)

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

```bash
$ npm run build && npm start
```

* E2E 테스트를 실행하기 전 DB의 테스트 데이터를 초기화하자. (이후 테스트의 영향을 미치기 때문)

```bash
$ npm run prisma:reset
```

### E2E 테스트 실행

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

```bash
$ npx playwright test
$ npx playwright test Login.spec.ts // 특정 테스트 파일만 테스트 실행
```

* 테스트 결과를 리포트로 생성하고 싶다면 `npx playwright show-report` 를 실행
  * 생성된 리포트는 `http://localhost:9223/` 에서 확인 가능

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

* E2E 테스트를 작성하다보면 기대한것과 다르게 테스트가 통과되지 않을 떄가 있음
* 이럴 땐 **플레이라이트 검사 도구**로 원인을 파악해야 한다.
* 테스트를 실행하는 커맨드에 `--debug` 옵션을 붙이면 `headed` 모드로 테스트가 시작됨
  * 브라우저를 열어서 육안으로 자동화된 UI 테스트를 확인할 수 있는 모드
* 검사 도구를 사용하면 실행 중인 테스트 코드를 보면서 UI가 어떻게 조작되는지 확인 가능
* 좌측 상단에 녹색 삼각형 재생 아이콘을 클릭하면 UI 테스트 시작됨
* 재생 아이콘 우측에 녹색 화살표 형태의 스텝 오버 아이콘을 클릭하면 한 줄 씩 테스트 코드가 실행됨

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

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

* 다른 테스트와 다르게 초기에 컨테이너를 빌드하는 시간이 필요
* CI용으로 작성한 것&#x20;

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

### 로그인 기능 E2E 테스트

* 로그인 여부에 따라 애플리케이션이 기대한 대로 작동하는지 검증
* 이를 검증하려면 로그인한 후에 특정 작업을 수행하는 인터렉션을 자주 사용해야 한다.
* 로그인 상태와 연관된 기능 테스트를 위해 반복적인 작업을 공통화하는것이 중요

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

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

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

#### 로그인 상태에서 로그아웃

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

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

```typescript
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");
}
```

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

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

* [예제 코드](https://github.com/frontend-testing-book-kr/nextjs/blob/main/src/lib/next/gssp.ts)
* 로그인 후에 리다이렉트 전 페이지로 돌아가는지 검증

```typescript
if (err instanceof UnauthroizedError) {
  session.redirectUrl = ctx.resolvedUrl;
  return { redirect: { permanent: false, destination: "/login" } };
}
```

```typescript
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` 객체는 세션에 저장된 정보를 불러옴

```typescript
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 응답을 반환한다.
* "대부분 코드가 프리즈마 의존성있는 코드라 일단 스킵"

```typescript
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 버튼을 누를 수 있어야 한다.

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

```typescript
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 테스트에는 새 기사를 작성하는 작업을 여러 번 실행해야 한다.
* 컨텐츠 내용은 신경쓰지 말고 필수 입력 항목만 채우는 인터렉션 함수를 만든다.
* 다른 테스트에 영향을 미치지 않도록 재사용 가능한 기사 작성 함수를 만들어보자.

<pre class="language-typescript"><code class="lang-typescript"><strong>//e2e/postUtils.ts
</strong>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);
}
</code></pre>

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

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

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

#### 신규 기사를 공개하는 함수

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

```typescript
//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 테스트&#x20;

#### 공통 함수

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

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

#### 검증

* 비공개 상태로 저장한 기사를 편집해 내용이 갱신되는지 검증
* 비공개 상태로 저장한 기사 공개 상태로 변경할 수 있는지 검증
* 공개된 기사를 편집하고 비공개 상태로 변경할 수 있는지 검증
* 공개된 기사를 삭제할 수 있는지 검증

```typescript
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 테스트
* 기사 목록은 메인 페이지와 마이 페이지 두 곳에 존재
  * 비공개 상태로 저장한 기사는 작성자 외 열람이 불가능하므로 메인 페이지에선 노출되지 않아야함

#### 검증

* 마이 페이지의 기사 목록에 신규 기사가 추가됐는지 검증
  * 비공개 상태로 저장한 기사와 공개 상태로 저장한 기사 모두 목록에 추가돼야함
* 메인 페이지의 기사 목록에 신규 기사가 추가됐는지 검증
  * 비공개 상태로 저장한 기사는 목록에 안보여야함

```typescript
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 테스트는 안정적이지 않다고 생각함
  * 불안정한 테스트는 네트워크 지연이나, 메모리 부족에 의한 서버 응답 지연 등 다양한 원인으로 발생함
  * 테스트 실행 순서의 영향으로 의도하지 않은 상태에서 테스트를 시작하여 문제가 발생하기도 함
* 이런 다양한 원인으로 <mark style="color:red;">**불안정한 테스트가 발생하는 것은 E2E 테스트에선 피할 수 없는 문제**</mark>
  * <mark style="color:red;">**몇가지 대처 방법은 존재한다.**</mark>

### 실행할 때마다 데이터베이스 재설정하기

* DB를 사용하는 E2E 테스트는 테스트를 실행하면 데이터가 변경된다.
* 일관성있는 결과를 얻으려면 테스트 시작 시점의 상태는 항상 동일해야 한다.
  * 테스트를 실행할 때마다 시드 스크립트로 초깃값을 재설정 해야함?

### 테스트마다 사용자를 새로 만들기

* 프로필 편집 기능을 테스트한다고 기존 사용자 정보를 변경하면 안됨
* 테스트에선 각 테스트를 위해 생성한 사용자를 사용해야 한다.
* 테스트가 끝나면 테스트용 사용자는 제거

### 테스트 간 리소스가 경합하지 않도록 주의

* CRUD 기능의 E2E 테스트처럼 각 테스트에서 매번 새로운 리소스를 작성하도록 해야함
* <mark style="color:purple;">**플레이라이트 테스트는 병렬처리되기 때문에 테스트 실행 순서를 보장할 수 없다.**</mark>
* 불안정한 테스트가 발견되면 리소스 경합이 발생하고 있는지 검토해야함

{% hint style="info" %}
**리소스 경합**이란?

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

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

* next.js 애플리케이션에서 개발할 때는 개발 서버에서 디버깅하며 코드를 작성하지만 <mark style="color:red;">**E2E 테스트는 개발 서버에서 실행하지 않도록 주의**</mark>해야 한다.
* 빌드한 Next.js 애플리케이션은 개발 서버와 다르게 동작하기 때문
* 또한 개발 서버는 응답 속도도 느려서 불안정한 테스트의 원인이 되기도함

### 비동기 처리 대기하기

* 예를들어 이미지 업로드 인터렉션이 있다면, 네트워크 통신이 완료될 때까지 기다려야 한다.
* 이처럼 시간이 걸리는 작업은 단위 테스트에서 했던것처럼 <mark style="color:red;">**비동기 처리 응답을 대기**</mark>해야 한다.
* 만약 조작할 요소가 존재하고 문제없이 인터렉션이 할당됐음에도 테스트가 실패한다면 비동기 처리를 제데로 대기하는지 확인해야함

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

* 불안정한 테스트의 원인을 파악하려면 디버거를 활용해야함
* 플레이라이트는 실행할 때 `--debug` 를 붙이면 디버거를 실행할 수 있음
* 직접 디버거에서 한 줄 씩 작동을 확인하며 실패 원인을 파악해보자.

### CI 환경과 CPU 코어 수 맞추기

* 로컬에서 테스트를 실행하는 경우 모두 통과하지만 CI 환경에서는 실패하기도 한다.
* 이 경우 <mark style="color:red;">**로컬 기기의 CPU 코어 수와 CI 코어수가 동일한지 체크**</mark>해야 한다.
* 플레이라이트나 제스트는 코어 수를 명시적으로 지정하지 않으면 실행 환경에서 실행 가능한 만큼 테스트 스위트를 병렬 처리한다.
  * 즉, 병렬처리되는 숫자는 실행 환경의 CPU 코어 수 떄문에 변동됨
* CPU 코어 수가 변도되지 않도록 고정하는 설정을 추가한다. (테스트 러너에 지정할 수 있음)
* <mark style="color:blue;">**CI의 코어 수에 맞춰 설정한 후, 로컬에서도 모든 테스트가 통과되면 문제 해결**</mark>
* 만약 <mark style="color:red;">**해결되지 않은 경우 대기 시간의 상한을 늘려보자.**</mark>
  * 전체적으로 보면 CI에서 테스트가 실패해 계속 재시도 하는 것보다 시간을 단축시킬 수 있다.

### 테스트 범위 최소화

* <mark style="color:red;">**상황에 따라 E2E 테스트로 검증하는 것이 적절한지 살펴봐야 한다.**</mark>
* 테스트 피라미드 상층부에 가까울 수록 <mark style="color:blue;">실제 상황과 유사한 테스트가 가능</mark>하지만, 반대로 <mark style="color:red;">불안정성이 증가하고 실행시간도 길어짐</mark>
* <mark style="color:purple;">**E2E 테스트를 더 넓은 범위의 통합 테스트로 대체가능하면 더 적은 비용으로 안정적인 테스트를 할 수 있다.**</mark>
* 검증 내용에 맞는 <mark style="color:orange;">최적의 테스트 범위를 찾아야 불안정한 테스트가 생길 가능성이 낮아진다.</mark>
