API 테스트

API 테스트 (API testing)

브라우저를 열지 않고도 애플리케이션의 REST API를 직접 테스트할 수 있다면 얼마나 편할까요? Playwright의 APIRequestContext를 쓰면 서버 API 테스트, 테스트 전 서버 상태 준비, 브라우저 작업 후 서버 상태 검증을 모두 할 수 있어요. GitHub API로 이슈 생성 테스트를 만들면서 흐름을 익혀 볼게요.

출처: Playwright 공식 문서 — API testing

본문

API 테스트란

Playwright는 애플리케이션의 REST API에 접근할 수 있어요. Node.js에서 페이지를 로드하거나 JS를 실행하지 않고 서버로 직접 요청을 보내고 싶을 때가 있죠. 유용한 예를 들면,

  • 서버 API 테스트
  • 테스트에서 웹 애플리케이션 방문 전에 서버 상태 준비
  • 브라우저에서 몇 가지 동작 후 서버 측 사후 조건 검증

이 모든 것은 [APIRequestContext] 메서드로 이뤄져요.

API 테스트 작성

[APIRequestContext]는 네트워크로 모든 종류의 HTTP(S) 요청을 보낼 수 있어요. 아래 예시는 GitHub API로 이슈 생성을 테스트하는데, 테스트 스위트는 이런 일을 해요.

  • 테스트 실행 전에 새 저장소 생성
  • 이슈 몇 개 생성 후 서버 상태 검증
  • 테스트 실행 후 저장소 삭제

설정

GitHub API는 인증이 필요하므로 모든 테스트에 쓸 토큰을 한 번 구성해요. 그 김에 baseURL도 설정해 테스트를 단순화할 수 있어요. 설정 파일에 넣거나 테스트 파일에서 test.use()로 넣으면 돼요.

import { defineConfig } from '@playwright/test';
export default defineConfig({
  use: {
    // All requests we send go to this API endpoint.
    baseURL: 'https://api.github.com',
    extraHTTPHeaders: {
      // We set this header per GitHub guidelines.
      'Accept': 'application/vnd.github.v3+json',
      // Add authorization token to all requests.
      // Assuming personal access token available in the environment.
      'Authorization': `token ${process.env.API_TOKEN}`,
    },
  }
});

테스트가 프록시 뒤에서 돌아야 한다면 config에서 지정할 수 있고 request 픽스처가 자동으로 받아 써요.

import { defineConfig } from '@playwright/test';
export default defineConfig({
  use: {
    proxy: {
      server: 'http://my-proxy:8080',
      username: 'user',
      password: 'secret'
    },
  }
});

테스트 작성

Playwright Test에는 내장 request 픽스처가 있어서 우리가 지정한 baseURL·extraHTTPHeaders 같은 설정을 존중하고 바로 요청을 보낼 준비가 돼 있어요. 저장소에 새 이슈를 만드는 테스트를 추가해 볼게요.

const REPO = 'test-repo-1';
const USER = 'github-username';

test('should create a bug report', async ({ request }) => {
  const newIssue = await request.post(`/repos/${USER}/${REPO}/issues`, {
    data: {
      title: '[Bug] report 1',
      body: 'Bug description',
    }
  });
  expect(newIssue.ok()).toBeTruthy();

  const issues = await request.get(`/repos/${USER}/${REPO}/issues`);
  expect(issues.ok()).toBeTruthy();
  expect(await issues.json()).toContainEqual(expect.objectContaining({
    title: '[Bug] report 1',
    body: 'Bug description'
  }));
});

test('should create a feature request', async ({ request }) => {
  const newIssue = await request.post(`/repos/${USER}/${REPO}/issues`, {
    data: {
      title: '[Feature] request 1',
      body: 'Feature description',
    }
  });
  expect(newIssue.ok()).toBeTruthy();

  const issues = await request.get(`/repos/${USER}/${REPO}/issues`);
  expect(issues.ok()).toBeTruthy();
  expect(await issues.json()).toContainEqual(expect.objectContaining({
    title: '[Feature] request 1',
    body: 'Feature description'
  }));
});

설정과 해제 (setup & teardown)

이 테스트들은 저장소가 존재한다고 가정해요. 실행 전에 새로 만들고 후에 지우기를 원할 텐데, 그때 beforeAllafterAll 훅을 쓰면 돼요.

test.beforeAll(async ({ request }) => {
  // Create a new repository
  const response = await request.post('/user/repos', {
    data: {
      name: REPO
    }
  });
  expect(response.ok()).toBeTruthy();
});

test.afterAll(async ({ request }) => {
  // Delete the repository
  const response = await request.delete(`/repos/${USER}/${REPO}`);
  expect(response.ok()).toBeTruthy();
});

request 컨텍스트 사용하기

사실 request 픽스처는 내부적으로 [method: APIRequest.newContext]를 호출해요. 더 많은 제어가 필요하면 직접 호출할 수도 있어요. 아래는 위의 beforeAll·afterAll과 같은 일을 하는 독립형 스크립트예요.

import { request } from '@playwright/test';
const REPO = 'test-repo-1';
const USER = 'github-username';

(async () => {
  // Create a context that will issue http requests.
  const context = await request.newContext({
    baseURL: 'https://api.github.com',
  });

  // Create a repository.
  await context.post('/user/repos', {
    headers: {
      'Accept': 'application/vnd.github.v3+json',
      // Add GitHub personal access token.
      'Authorization': `token ${process.env.API_TOKEN}`,
    },
    data: {
      name: REPO
    }
  });

  // Delete a repository.
  await context.delete(`/repos/${USER}/${REPO}`, {
    headers: {
      'Accept': 'application/vnd.github.v3+json',
      // Add GitHub personal access token.
      'Authorization': `token ${process.env.API_TOKEN}`,
    }
  });
})();

UI 테스트에서 API 요청 보내기

브라우저 안에서 테스트를 돌리면서 애플리케이션 HTTP API를 호출하고 싶을 수 있어요. 테스트 실행 전 서버 상태를 준비하거나 브라우저에서 동작 후 서버의 사후 조건을 확인할 때 유용하죠.

사전 조건 설정

다음 테스트는 API로 새 이슈를 만든 뒤 프로젝트의 이슈 목록으로 이동해 그 이슈가 목록 맨 위에 나오는지 확인해요.

import { test, expect } from '@playwright/test';

const REPO = 'test-repo-1';
const USER = 'github-username';

// Request context is reused by all tests in the file.
let apiContext;

test.beforeAll(async ({ playwright }) => {
  apiContext = await playwright.request.newContext({
    // All requests we send go to this API endpoint.
    baseURL: 'https://api.github.com',
    extraHTTPHeaders: {
      // We set this header per GitHub guidelines.
      'Accept': 'application/vnd.github.v3+json',
      // Add authorization token to all requests.
      // Assuming personal access token available in the environment.
      'Authorization': `token ${process.env.API_TOKEN}`,
    },
  });
});

test.afterAll(async ({ }) => {
  // Dispose all responses.
  await apiContext.dispose();
});

test('last created issue should be first in the list', async ({ page }) => {
  const newIssue = await apiContext.post(`/repos/${USER}/${REPO}/issues`, {
    data: {
      title: '[Feature] request 1',
    }
  });
  expect(newIssue.ok()).toBeTruthy();

  await page.goto(`https://github.com/${USER}/${REPO}/issues`);
  const firstIssue = page.locator(`a[data-hovercard-type='issue']`).first();
  await expect(firstIssue).toHaveText('[Feature] request 1');
});

사후 조건 검증

다음 테스트는 브라우저 UI로 새 이슈를 만든 뒤 API로 그것이 생성됐는지 확인해요.

import { test, expect } from '@playwright/test';

const REPO = 'test-repo-1';
const USER = 'github-username';

// Request context is reused by all tests in the file.
let apiContext;

test.beforeAll(async ({ playwright }) => {
  apiContext = await playwright.request.newContext({
    // All requests we send go to this API endpoint.
    baseURL: 'https://api.github.com',
    extraHTTPHeaders: {
      // We set this header per GitHub guidelines.
      'Accept': 'application/vnd.github.v3+json',
      // Add authorization token to all requests.
      // Assuming personal access token available in the environment.
      'Authorization': `token ${process.env.API_TOKEN}`,
    },
  });
});

test.afterAll(async ({ }) => {
  // Dispose all responses.
  await apiContext.dispose();
});

test('last created issue should be on the server', async ({ page }) => {
  await page.goto(`https://github.com/${USER}/${REPO}/issues`);
  await page.getByText('New Issue').click();
  await page.getByRole('textbox', { name: 'Title' }).fill('Bug report 1');
  await page.getByRole('textbox', { name: 'Comment body' }).fill('Bug description');
  await page.getByText('Submit new issue').click();
  const issueId = new URL(page.url()).pathname.split('/').pop();

  const newIssue = await apiContext.get(
      `https://api.github.com/repos/${USER}/${REPO}/issues/${issueId}`
  );
  expect(newIssue.ok()).toBeTruthy();
  expect(newIssue.json()).toEqual(expect.objectContaining({
    title: 'Bug report 1'
  }));
});

인증 상태 재사용

웹 앱은 쿠키·토큰 기반 인증을 쓰고 인증 상태는 쿠키로 저장돼요. Playwright의 [method: APIRequestContext.storageState]로 인증된 컨텍스트에서 저장 상태를 가져와 그 상태로 새 컨텍스트를 만들 수 있어요. 저장 상태는 [BrowserContext]와 [APIRequestContext] 사이에서 서로 교환 가능해요 — API 호출로 로그인한 뒤 쿠키가 이미 있는 새 컨텍스트를 만들 수 있죠.

const requestContext = await request.newContext({
  httpCredentials: {
    username: 'user',
    password: 'passwd'
  }
});
await requestContext.get(`https://api.example.com/login`);
// Save storage state into the file.
await requestContext.storageState({ path: 'state.json' });

// Create a new context with the saved storage state.
const context = await browser.newContext({ storageState: 'state.json' });

컨텍스트 요청 vs 전역 요청

[APIRequestContext]에는 두 종류가 있어요.

  • [BrowserContext]와 연관된 것
  • [method: APIRequest.newContext]로 만든 독립 인스턴스

핵심 차이는, [property: BrowserContext.request]·[property: Page.request]로 접근하는 컨텍스트 요청은 브라우저 컨텍스트에서 요청의 Cookie 헤더를 채우고, [APIResponse]에 Set-Cookie 헤더가 있으면 브라우저 쿠키를 자동으로 갱신한다는 거예요.

test('context request will share cookie storage with its browser context', async ({
  page,
  context,
}) => {
  await context.route('https://www.github.com/', async route => {
    // Send an API request that shares cookie storage with the browser context.
    const response = await context.request.fetch(route.request());
    const responseHeaders = response.headers();

    // The response will have 'Set-Cookie' header.
    const responseCookies = new Map(responseHeaders['set-cookie']
        .split('\n')
        .map(c => c.split(';', 2)[0].split('=')));
    // The response will have 3 cookies in 'Set-Cookie' header.
    expect(responseCookies.size).toBe(3);
    const contextCookies = await context.cookies();
    // The browser context will already contain all the cookies from the API response.
    expect(new Map(contextCookies.map(({ name, value }) =>
      [name, value])
    )).toEqual(responseCookies);

    await route.fulfill({
      response,
      headers: { ...responseHeaders, foo: 'bar' },
    });
  });
  await page.goto('https://www.github.com/');
});

[APIRequestContext]가 브라우저 컨텍스트의 쿠키를 쓰고 갱신하는 걸 원하지 않는다면, 고유한 격리 쿠키를 가진 새 인스턴스를 직접 만들면 돼요.

test('global context request has isolated cookie storage', async ({
  page,
  context,
  browser,
  playwright
}) => {
  // Create a new instance of APIRequestContext with isolated cookie storage.
  const request = await playwright.request.newContext();
  await context.route('https://www.github.com/', async route => {
    const response = await request.fetch(route.request());
    const responseHeaders = response.headers();

    const responseCookies = new Map(responseHeaders['set-cookie']
        .split('\n')
        .map(c => c.split(';', 2)[0].split('=')));
    // The response will have 3 cookies in 'Set-Cookie' header.
    expect(responseCookies.size).toBe(3);
    const contextCookies = await context.cookies();
    // The browser context will not have any cookies from the isolated API request.
    expect(contextCookies.length).toBe(0);

    // Manually export cookie storage.
    const storageState = await request.storageState();
    // Create a new context and initialize it with the cookies from the global request.
    const browserContext2 = await browser.newContext({ storageState });
    const contextCookies2 = await browserContext2.cookies();
    // The new browser context will already contain all the cookies from the API response.
    expect(
        new Map(contextCookies2.map(({ name, value }) => [name, value]))
    ).toEqual(responseCookies);

    await route.fulfill({
      response,
      headers: { ...responseHeaders, foo: 'bar' },
    });
  });
  await page.goto('https://www.github.com/');
  await request.dispose();
});

더 알아보기