런타임 테스트 코드

조아영·2024년 10월 11일

◼ 목적과 이점

  • 안정성 및 신뢰성 확보: 정기적인 테스트를 통해 애플리케이션이 안정적으로 작동하며, 예상치 못한 사용자 행동이나 데이터 입력에도 견딜 수 있는지를 확인할 수 있습니다.
  • 코드 동작 테스트: 애플리케이션 내부의 비즈니스 로직이 의도한 대로 제대로 작동하는지를 검증할 수 있습니다.
  • 상호작용 테스트: 사용자와의 상호작용, 클릭, 스크롤, 입력 등이 정상적으로 작동하는지 확인할 수 있습니다.
  • 성능 평가: 프론트엔드 테스트를 통해 애플리케이션의 로딩 시간과 반응 속도 등 성능 지표를 평가하여 사용자 경험을 개선할 수 있는 방안을 찾을 수 있습니다.
  • 크로스 브라우저 호환성: 다양한 웹 브라우저와 디바이스에서 애플리케이션이 일관되게 작동하는지를 테스트할 수 있습니다.
  • 인터페이스 검증: 사용자 인터페이스(UI)가 설계 사양에 맞게 구현되었는지를 확인할 수 있습니다.

◼ 테스트의 7원칙

1. 테스트는 결함이 존재함을 밝히는 활동

테스트를 완료하고 발견된 이슈를 모두 해결했더라도 결함이 없다는 것을 완전히 증명할 수는 없습니다. 이슈가 발견되지 않았다고 해서 결함이 없다고 단정할 수도 없습니다. 테스트는 프로그램에서 결함이 없음을 보장하기보다는 결함이 존재함을 밝히는 활동입니다.

2. 완벽한 테스트는 불가능

매우 단순한 소프트웨어가 아닌 이상 내부 조건, 입력값, 타이밍에 대한 모든 조합을 확인하는 것은 불가능합니다. 따라서 테스트 대상을 분석한 후 가장 중요한 부분에 중점을 두어 테스트 리소스를 투입해야 합니다.

3. 테스트는 개발 초기 단계부터 시작

요구 사항 분석과 설계 단계부터 테스트를 진행하면 문서상의 결함을 발견할 수 있으며, 이러한 결함은 코딩 이후에 발견된 결함보다 쉽게 해결할 수 있습니다. 또한 조기에 테스트 설계를 완료하면 코딩이 끝나자마자 테스트를 실행할 수 있어 테스트 기간을 단축할 수 있습니다.

4. 결함 집중 현상

대다수의 결함은 특정 모듈에 집중되며, 이 모듈에서 장애가 발생할 가능성이 큽니다.

  • 자체적으로 복잡한 구조를 가지고 있는 모듈
  • 소프트웨어의 다른 부분과 복잡한 상호 작용을 하는 모듈
  • 개발 난이도가 높거나 최신 기술을 사용한 모듈
  • 기존의 것을 사용하지 않고 새롭게 개발한 모듈
  • 크기가 큰 모듈
  • 경험이 미흡한 개발팀에서 개발한 모듈

5. 살충제 패러독스

동일한 테스트 케이스를 반복해서 수행하면 더 이상 새로운 결함을 찾기 어려워집니다. 이를 극복하려면 새로운 테스트 기법이나 다른 시각에서 테스트 케이스를 정기적으로 변경해야 합니다.

6. 테스트는 상황에 맞게

소프트웨어의 종류와 목표에 따라 적합한 테스트 방식이 적용되어야 합니다. 예산, 일정, 리스크, 환경 등을 고려하여 적절한 테스트 전략을 수립해야 합니다.

7. 오류 부재의 오류

모든 결함을 수정했더라도 사용자의 요구나 비즈니스 목적을 충족하지 못한다면 높은 품질의 소프트웨어라고 할 수 없습니다.

◼ 테스트 피라미드

◼ 단위 테스트 (Unit Testing)

  • 정의: 단위 테스트는 테스트 피라미드의 가장 아래층을 구성하며, 프론트엔드에서 개별 컴포넌트, 함수 또는 메서드가 예상대로 작동하는지 검증하는 테스트입니다.
  • 목적: 개별적인 UI 컴포넌트나 유틸리티 함수가 정확하게 작동하는지 확인하여 전체 애플리케이션의 신뢰성을 높입니다.

단위 테스트를 하는 이유

  • 빠른 작성 및 실행: 개발 초기 단계에서 버그를 빠르게 발견하고 수정할 수 있습니다.
  • 안정적인 코드 변경: 기존 코드를 수정하거나 개선할 때, 단위 테스트가 기능의 올바른 작동을 보장합니다.
  • 문서화 및 이해도 향상: 단위 테스트는 코드의 기능을 설명하며, 새로운 개발자가 코드베이스를 이해하는 데 도움을 줍니다.

단위 테스트 도구

  • Jest: Facebook이 개발한 도구로, React 애플리케이션에서 널리 사용됩니다.
  • React Testing Library: 사용자 관점에서 컴포넌트를 테스트할 수 있는 도구로, DOM에 대한 접근을 통해 컴포넌트의 렌더링 결과를 검증합니다.
  • Vitest: Vite 프로젝트에 최적화된 테스트 러너로, 빠른 실행 속도를 제공합니다.

예시

React 컴포넌트 테스트

  import React from 'react';

  const Button = ({ onClick, children }) => {
    return <button onClick={onClick}>{children}</button>;
  };

  export default Button;
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import Button from './Button';

test('버튼 클릭 시 콜백 함수가 호출되어야 함', () => {
  const handleClick = jest.fn();
  const { getByText } = render(<Button onClick={handleClick}>클릭</Button>);

  fireEvent.click(getByText('클릭'));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

유틸리티 함수 테스트

export function formatDate(date) {
    return date.toISOString().split("T")[0];
}
import { formatDate } from './formatDate';

test('날짜를 YYYY-MM-DD 형식의 문자열로 변환해야 함', () => {
  const date = new Date('2023-10-11T00:00:00Z');
  expect(formatDate(date)).toBe('2023-10-11');
}

◼ 통합 테스트 (Integration Testing)

  • 정의: 통합 테스트는 테스트 피라미드의 중간 층을 차지하며, 여러 컴포넌트나 모듈이 함께 올바르게 작동하는지 확인하는 테스트입니다.
  • 목적: 단위 테스트로 검증된 컴포넌트들이 함께 조합될 때 발생할 수 있는 문제를 식별합니다.

통합 테스트를 하는 이유

  • 시스템 안정성 확보: 여러 UI 컴포넌트나 모듈이 통합되어 예상대로 작동하는지 확인합니다.
  • 인터페이스 오류 발견: 컴포넌트 간 데이터 전달 및 상호작용 오류를 식별합니다.
  • 데이터 흐름 검증: 상태 관리나 API 호출에서 데이터가 올바르게 전달되는지 검증합니다.

통합 테스트 도구

  • 단위 테스트와 유사합니다.

예시

컴포넌트 간 상호작용 테스트

import React, { useState } from "react";

const Counter = () => {
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>현재 카운트: {count}</p>
            <button onClick={() => setCount(count + 1)}>증가</button>
        </div>
    );
};

export default Counter;
// Counter.test.jsx
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import Counter from "./Counter";

test("버튼 클릭 시 카운트가 증가해야 함", () => {
    const { getByText } = render(<Counter />);
    const counterText = getByText("현재 카운트: 0");
    const button = getByText("증가");

    fireEvent.click(button);
    expect(counterText.textContent).toBe("현재 카운트: 1");
});

API 호출 및 상태 관리 테스트

import React, { useEffect, useState } from "react";

const UserList = () => {
    const [users, setUsers] = useState([]);

    useEffect(() => {
        fetch("/api/users")
            .then((res) => res.json())
            .then((data) => setUsers(data));
    }, []);

    return (
        <ul>
            {users.map((user) => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
};

export default UserList;
import React from "react";
import { render, waitFor } from "@testing-library/react";
import UserList from "./UserList";

beforeEach(() => {
    global.fetch = jest.fn(() =>
        Promise.resolve({
            json: () =>
                Promise.resolve([
                    { id: 1, name: "홍길동" },
                    { id: 2, name: "김철수" },
                ]),
        })
    );
});

test("사용자 목록을 렌더링해야 함", async () => {
    const { getByText } = render(<UserList />);

    await waitFor(() => {
        expect(getByText("홍길동")).toBeInTheDocument();
        expect(getByText("김철수")).toBeInTheDocument();
    });
});

◼ E2E 테스트 (End-to-End Testing)

  • 정의: E2E 테스트는 테스트 피라미드의 꼭대기에 위치하며, 실제 브라우저 환경에서 사용자의 행동을 모방하여 애플리케이션의 전체 흐름을 테스트하는 것입니다.
  • 목적: 애플리케이션이 실제 운영 환경에서 예상대로 작동하는지 확인하여 비즈니스 요구 사항을 충족하는지 검증합니다.

E2E 테스트를 하는 이유

  • 사용자 플로우 검증: 사용자가 애플리케이션을 사용하는 전체 흐름을 테스트하여 모든 기능이 정상적으로 작동하는지 확인합니다.
  • 통합 문제 식별: 프론트엔드와 백엔드 간의 통합 문제를 확인할 수 있습니다.
  • 실제 환경 검증: 실제 브라우저 환경에서 테스트를 수행하여 운영 환경과 개발 환경의 차이로 인한 문제를 발견합니다.

E2E 테스트 도구

  • Cypress: 현대적인 E2E 테스트 도구로, 설치와 구성이 간단하며, 테스트 작성과 디버깅이 용이합니다. 개발자 친화적인 UI와 빠른 실행 속도로 많은 사랑을 받고 있습니다.
  • Playwright: Microsoft에서 개발한 E2E 테스트 도구로, Chromium, Firefox, WebKit 등 다양한 브라우저를 지원합니다. 여러 브라우저에서 동시에 테스트할 수 있는 기능이 뛰어나며, 강력한 테스트 자동화 기능을 제공합니다.
  • Selenium WebDriver: 전통적인 E2E 테스트 도구로, 다양한 프로그래밍 언어와 브라우저를 지원합니다. 오래된 도구지만 여전히 많은 기업에서 사용되고 있으며, 대규모 프로젝트에서도 활용될 수 있는 안정성을 갖추고 있습니다.

예시

회원 가입 및 로그인 흐름 테스트 (Cypress)

describe("회원 가입 및 로그인 E2E 테스트", () => {
    it("사용자가 회원 가입하고 로그인할 수 있어야 함", () => {
        // 회원 가입 페이지로 이동
        cy.visit("http://localhost:3000/signup");

        // 회원 가입 폼 입력 및 제출
        cy.get('input[name="username"]').type("testuser");
        cy.get('input[name="email"]').type("testuser@example.com");
        cy.get('input[name="password"]').type("password123");
        cy.get('button[type="submit"]').click();

        // 회원 가입 성공 메시지 확인
        cy.contains("회원 가입이 완료되었습니다").should("be.visible");

        // 로그인 페이지로 이동
        cy.visit("http://localhost:3000/login");

        // 로그인 폼 입력 및 제출
        cy.get('input[name="email"]').type("testuser@example.com");
        cy.get('input[name="password"]').type("password123");
        cy.get('button[type="submit"]').click();

        // 로그인 성공 후 대시보드 확인
        cy.url().should("include", "/dashboard");
        cy.contains("testuser님 환영합니다").should("be.visible");
    });
});

상품 구매 흐름 테스트 (Playwright)

const { test, expect } = require("@playwright/test");

test("상품 검색부터 구매까지의 E2E 테스트", async ({ page }) => {
    // 메인 페이지로 이동
    await page.goto("http://localhost:3000");

    // 상품 검색
    await page.fill('input[placeholder="검색"]', "헤드폰");
    await page.press('input[placeholder="검색"]', "Enter");

    // 검색 결과에서 상품 선택
    await page.click('.product-item:has-text("헤드폰")');

    // 상품 상세 페이지에서 '구매하기' 클릭
    await page.click("text=구매하기");

    // 결제 페이지에서 정보 입력
    await page.fill('input[name="cardNumber"]', "1234 5678 9012 3456");
    await page.fill('input[name="expiryDate"]', "12/25");
    await page.fill('input[name="cvv"]', "123");

    // 구매 완료
    await page.click('button[type="submit"]');

    // 구매 완료 확인
    await expect(page).toHaveURL(/.*\/order\/confirmation/);
    await expect(page.locator("text=구매가 완료되었습니다")).toBeVisible();
});

◼ 테스트를 하기 전에 알아야 할 것

1. 함수 시그니처를 명확히 정할 것

  • 이 함수가 무엇을 하려는지 명확히 정의해야 합니다. (SRP: 단일 책임 원칙)
  • 입력값과 출력값이 안전하게 설정되었는지 확인해야 합니다. early return과 같은 안전한 반환 처리가 되어 있는지 확인합니다.
  • 불필요한 예외(Exception)가 발생하지 않도록 주의해야 합니다.
  • 함수가 순수함수인지, 멱등성을 지키고 있는지, 그리고 매직 넘버(Magic Number)가 없는지 검토해야 합니다.

2. 정적 분석 도구를 적극 활용

  • TypeScript의 정적 타입 검사를 적극적으로 활용해야 합니다.
  • ESLint와 같은 정적 분석 도구를 통해 코드를 검토하고 문제를 사전에 방지해야 합니다.

3. 엣지 케이스(Edge Case)를 명확히 정의할 것

  • 의도된 실패 케이스에 대해 충분히 고려해야 합니다.
  • 실패한 케이스가 무엇인지 데이터 기반으로 명확하게 정의하고, 발생할 수 있는 예외 상황을 사전에 예측해야 합니다.

0개의 댓글