시작하며..

최근에 Creative Storage라는 프로젝트를 진행하였습니다.
TDD를 도입하여 프로세스를 진행하기로 하였었고, Frontend에서 React를 사용하였기 때문에, React Component를 테스트 해야할 필요가 생겼습니다.
그래서 환경 구성을 시도해보았는데 쉽지 않았어서 멘토분에게 도움을 요청하였습니다.
얻게 된 멘토분의 해결방법이 저에겐 굉장히 신선했기 때문에 포스팅합니다.

이 포스팅은 TypeScript 환경에서 동작하는 구성입니다.
JavaScript에서도 몇 가지를 바꾸어주면 동작하겠지만,
TypeScript환경에 관한 내용만 작성합니다.

패키지 설치는 한번에해도 좋지만, 각각 패키지의 필요성을 같이 흐름에 따라 설명하기 위해 흐름에 따라 설치하도록 하겠습니다.

필요없는 분들을 위해서 미리 말씀드리자면, 사용하는 패키지는 아래와 같습니다.

필요 패키지

npm i -D jest jest-puppeteer ts-jest puppeteer expect @types/puppeteer @types/jest-environment-puppeteer @types/expect-puppeteer @testing-library/react

일단, 테스팅 라이브러리인 jest를 설치해줍니다.

npm i -D jest

설치 후, jest의 설정 파일을 생성합니다.(프로젝트의 루트 디렉토리에서)

touch jest.config.js

jest.config.js

module.exports = {
  preset: "jest-puppeteer",
  testMatch: ["**/?(*.)+(spec|test).[t]s"],
  moduleNameMapper: {
    "^src(.*)$": "<rootDir>/src$1",
  },
  transform: {
    "^.+\\.ts?$": "ts-jest",
    "^.+\\.tsx?$": "ts-jest",
    "\\.js$": "ts-jest",
  },
  globalSetup: "./src/test/setup.ts",
  globalTeardown: "./src/test/teardown.ts",
};

각각의 키 값의 의미에 대하여 알아보겠습니다.

의미
preset jest configuration의 base로 사용된다
testMatch 패턴에 해당하는 파일을 test파일로 인식한다.
moduleNameMapper test 파일 안에 있는 모듈이름을 변환시켜준다.
transform 패턴에 해당하는 파일에 대한 전처리기를 지정해준다.
globalSetup 테스트 전에 실행되는 global setup 모듈을 지정할 수 있다.
globalTeardown 테스트가 끝난 후에 실행되는 global teardown 모듈을 지정할 수 있다.

setup.ts 파일 부터 살펴보도록 하겠습니다.

setup.ts

import fs, { Dirent } from "fs";  // fs 모듈과 Dirent 타입을 불러온다
import path from "path"; // browser 환경에서 test가 동작하므로 nodejs의 path모듈이 존재하지 않기때문에 불러온다. 
import { startBundleSever } from "./browserTest/settings/bundleServer"; // bundler에서 제공하는 서버를 시작하는 함수를 불러온다
import { port, settingsPath, testEnvPath } from "./env"; // 사용할 port, setting 파일이 들어있는 path, 테스트 환경이 들어있는 Path를 가져온다
// tslint:disable-next-line:no-var-requires
const { setup: setupPuppeteer } = require("jest-environment-puppeteer");  
// jest-environment-puppeteer : jest와 puppeteer를 사용해서 테스트를 실행한다.
// setupPuppeteer란 변수를 선언함과 동시에 jest-environment-puppeteer 모듈의 setup 함수를 setupPuppeteer에 할당해준다.
// import를 사용하지 않은 것은 jest-environment-puppeteer 모듈이 CommonJS를 따르기 때문.
import puppeteer from "puppeteer"; // puppeteer 모듈을 불러온다.

function readdir(directory: string): Promise<Dirent[]> {
  return new Promise((resolve, reject) => {
    fs.readdir(
      directory,
      {
        withFileTypes: true,
      },
      (error, dirents) => {
        if (error) {
          reject();
          return;
        }

        resolve(dirents);
      },
    );
  });
}

async function getFilesEndsWithRecursively(
  directory: string,
  endsOfPaths: string[],
): Promise<string[]> {
  // readdir 함수는 Dirent[] 를 리턴한다.
  const items = await readdir(directory);
  const testCodePaths: string[] = [];
  await Promise.all(
    items.map(async (item) => {
      const itemPath = path.join(directory, item.name);
      if (!itemPath) {
        return;
      }
      // .browsertest.ts 혹은 .browsertest.tsx로 끝나는 파일이면 testCodePaths에 푸시한다.
      if (item.isFile() && endsOfPaths.some((x) => itemPath.endsWith(x))) {
        testCodePaths.push(itemPath);
      }
      // 폴더일 경우 재귀적으로 폴더 안까지 확인한다.
      if (item.isDirectory()) {
        const codePaths = await getFilesEndsWithRecursively(
          itemPath,
          endsOfPaths,
        );
        testCodePaths.push(...codePaths);
      }
    }),
  );

  return testCodePaths;
}

async function setRequires() {
  const browserTestDirectoryPath = path.join(__dirname, "browserTest");

  // 브라우저에서 테스트해야하는 코드는 .browsertest.ts 혹은 .browsertest.tsx로 약속한다.
  // 테스트 파일이 들어있는 폴더 내에서 해당 확장자인 파일들을 읽는다.
  const browserTestCodePaths = await getFilesEndsWithRecursively(
    browserTestDirectoryPath,
    [".browsertest.ts", ".browsertest.tsx"],
  );
  const requiresFilePath = path.join(settingsPath, "requires.ts");

  // 각 파일의 requires.ts에 대한 상대경로를 구하고,
  // console.log(require("~~~~")); 형태의 문자열로 만들어
  // requires.ts 파일에 writeFile 함수를 이용해 써넣는다.
  const requiresFileContent = browserTestCodePaths
    .map((browserTestCodePath) =>
      path.relative(settingsPath, browserTestCodePath),
    )
    .map(
      (browserTestCodePath) =>
        `console.log(require("${browserTestCodePath.replace(/\\/g, "/")}"));`,
    )
    .join("\n");

  await new Promise((resolve, reject) => {
    fs.writeFile(requiresFilePath, requiresFileContent, (err) => {
      if (err) {
        reject(err);
        return;
      }

      resolve();
    });
  });
}

async function setTestEnv() {
  // puppeteer를 실행한다.
  const browser = await puppeteer.launch();
  // 브라우저의 새 페이지를 연다.
  const page = await browser.newPage();
  // 실행해둔 bundle server의 주소로 접속한다.
  await page.goto(`http://localhost:${port}`);
  // css selector id=root가 렌더 될 때 까지 기다린다.
  await page.waitForSelector("#root");

  // BrowserTest.ts에서 브라우저 컨텍스트 전역변수로 보낸 itTestCaseNames 들을 String[]의 형태로 가져온다.
  const itTestCaseNames = (await page.evaluate(() => {
    return (window as any).itTestCaseNames;
  })) as string[];

  // testEnv 파일에 문자열 testEnvContent를 쓴다
  const testEnvContent = `export const itTestCaseNames = ${JSON.stringify(
    itTestCaseNames,
    null,
    2,
  )};`;
  await new Promise((resolve, reject) => {
    fs.writeFile(testEnvPath, testEnvContent, { encoding: "utf-8" }, (err) => {
      if (err) {
        reject(err);
        return;
      }
      resolve();
    });
  });

  await browser.close();
}

module.exports = async (globalConfig: any) => {
  // puppeteer를 셋팅한다.
  // https://github.com/smooth-code/jest-puppeteer/blob/master/packages/jest-environment-puppeteer/src/global.js
  await setupPuppeteer(globalConfig);

  // 파일 내부 함수 참조.
  await setRequires();

  // 특정포트로 서버를 실행한다.
  // 웹페이지에서 테스트하려면 접속할 수 있는 주소가 필요하다.
  await startBundleSever(port);

  // 파일 내부 함수 참조.
  await setTestEnv();
};

위에서 임포트 한 파일들입니다.
setup.ts에서 동작하는 부분은 module.exports하는 부분이므로,
그 부분을 따라서 읽으시면 되겠습니다.

bundleServer.ts

bundler에서 제공하는 서버를 실행하는 파일입니다.
테스트 환경을 호스팅합니다.

import Bundler, { ParcelOptions } from "parcel-bundler";
import path from "path";
import os from "os";
import { Server } from "http";

const outDir = os.tmpdir();

const file = path.join(__dirname, "./index.html");

// index.html 파일에서 BrowerTest.ts를 import함.
const options: ParcelOptions = {
  outDir,
  outFile: "index.html",
  watch: false,
};

const bundler = new Bundler(file, options);

let server: Server;

export async function startBundleSever(port: number): Promise<void> {
  server = await bundler.serve(port);
}

export function stopBundleServer() {
  server.close();
}

browserTest.ts

// 모든 .browsertest.ts 혹은 .borwsertest.tsx 확장자를 가진 파일들을 임포트한다.
// 임포트 하고나면 it.ts 파일에 테스트 케이스의 이름과, 각각 실행될 메소드가 객체형식으로 저장된다.
import "src/test/browserTest/settings/requires"; 
import {
  itTestCaseList,
  itTestCaseNames,
} from "src/test/browserTest/settings/it";

async function runTest(testCaseName: string): Promise<void> {
  await itTestCaseList[testCaseName]();
}

// bundle server에 접속했을 때 실행 가능하도록 전역에 선언한다.
(window as any).runTest = runTest;
(window as any).itTestCaseNames = itTestCaseNames;

setup.ts에서 export된 모듈내의 로직이 실행되고 나면,
jest.config.js에서 찾은 테스트 파일들이 실행됩니다.

runBrowsertTest.test.ts

// testEnv 파일에는 test case 들의 이름들이 들어있음.
import { itTestCaseNames } from "src/test/browserTest/settings/testEnv";
import { port } from "src/test/env";

describe("Test in browser", () => {
  beforeAll(async () => {
    jest.setTimeout(20000);

    await page.goto(`http://localhost:${port}`);

    await page.waitForSelector("#root");
    jest.setTimeout(5000);
  });

  // browser context에서 각각의 테스트를 수행한다.
  itTestCaseNames.forEach((testCaseName) => {
    it(testCaseName, async () => {
      await page.evaluate(`
        runTest("${testCaseName}");
      `);
    });
  });
});

모든 테스트를 수행한 후
jest.config.js에서 설정한 globalTeardown 모듈에 따라 마무리 작업을 수행합니다.

teardown.ts

import {
  stopBundleServer,
} from "./browserTest/settings/bundleServer";
// tslint:disable-next-line:no-var-requires
const { teardown: teardownPuppeteer } = require("jest-environment-puppeteer");

module.exports = async (globalConfig: any) => {
  await teardownPuppeteer();
  await stopBundleServer();
};

프로젝트를 진행하면서 이런 방식으로 React Component가 render된 상황에서 테스트하였습니다. 새로운 방식의 환경 구성을 알게되어서 시야가 넓어지는 느낌이었고, 비슷한 환경을 구성해야하는 다른 분들에게도 도움이 되었으면 좋겠습니다.

추가로 프로젝트의 저장소를 공개합니다.
Creative Storage 프로젝트 저장소