테스트 코드 작성 입문 | Arrange-Act-Assert(AAA) 패턴으로 단위 테스트의 기본 구조 이해하기

pytest로 테스트를 작성하기 시작하면 “테스트 코드를 어떻게 구성해야 할까?”라고 고민하게 됩니다. 그럴 때 꼭 알아두고 싶은 것이 Arrange-Act-Assert(AAA 패턴)입니다. 「준비 → 실행 → 확인」의 3단 구조로 작성하기만 하면, 읽기 쉽고 유지보수하기 쉬운 테스트 코드를 작성할 수 있게 됩니다.

📌 이 글의 대상 독자

✅ pytest를 막 시작해서 테스트 코드 작성법을 모르는 분
✅ 테스트를 작성해봤지만 읽기 어렵거나 무엇을 테스트하는지 알기 어렵다고 느끼는 분
✅ 「좋은 테스트 코드」의 기준을 알고 싶은 QA 엔지니어·개발자
✅ pytest 가이드로 나아가기 전에 기초를 다지고 싶은 분

✅ 이 글을 읽으면 얻을 수 있는 것

  • AAA 패턴(Arrange·Act·Assert)의 역할과 작성법을 이해할 수 있다
  • 「1테스트 1목적」 원칙과 읽기 쉬운 테스트 함수명 작성법을 알 수 있다
  • pytest에서 실전적인 테스트 코드 예시를 얻을 수 있다
  • 자주 하는 실수와 그 수정법을 알 수 있다

👨‍💻 이 글의 신뢰성에 대해

본 글은 QA 엔지니어로서 15년 이상 테스트 자동화에 종사해 온 필자가 작성했습니다. 「테스트는 작성할 수 있지만 팀원이 읽어주지 않는다」는 경험에서 AAA 패턴의 중요성을 실감하여, 현재 현장에서도 표준으로 채택하고 있습니다.

📌 이 글의 결론

  • 테스트 코드는 Arrange(준비) → Act(실행) → Assert(검증)의 3단 구조로 작성한다
  • 하나의 테스트 함수에서 하나의 목적만 확인하는 것이 기본
  • 함수명에 무엇을·어떤 조건에서·어떤 결과가 나오는지를 포함하면 가독성이 올라간다

테스트 코드를 처음 작성했을 때, 필자도 이런 코드를 작성했습니다.

# ❌ 구조가 없어 무엇을 테스트하는지 알기 어렵다
def test_calc():
    assert add(1, 2) == 3
    assert add(-1, 1) == 0
    assert add(0, 0) == 0
    result = add(100, 200)
    assert result == 300
    assert add(1.5, 2.5) == 4.0

얼핏 보면 문제없어 보이지만, 어느 하나가 실패했을 때 「어떤 테스트가 실패한 건지」 바로 알 수 없습니다. 또 여러 검증이 혼재해 있어 테스트 의도가 전달되기 어렵습니다. 이 문제를 해결하는 것이 Arrange-Act-Assert 패턴입니다.

Arrange-Act-Assert(AAA) 패턴이란?

AAA 패턴이란 테스트 코드를 3개의 페이즈로 나눠서 작성하는 구조입니다. 어떤 프로그래밍 언어·테스트 프레임워크에서도 사용할 수 있는 보편적인 패턴으로, 테스트 업계에서는 사실상 표준으로 널리 사용되고 있습니다.

Arrange
준비
Act
실행
Assert
검증
페이즈역할구체적으로 하는 것
Arrange(준비)테스트에 필요한 상태를 갖춘다변수 정의, 객체 생성, mock 설정 등
Act(실행)테스트 대상의 처리를 실행한다함수 호출, API 요청, 버튼 클릭 등
Assert(검증)결과가 기대대로인지 확인한다assert 문으로 값·상태·예외 등을 검증

AAA 패턴의 기본형

앞서의 테스트를 AAA 패턴으로 다시 작성하면 이렇게 됩니다.

# ✅ AAA 패턴으로 작성한 경우
def test_add_returns_correct_sum_with_positive_numbers():
    # Arrange(준비): 테스트에 필요한 데이터를 준비한다
    a = 2
    b = 3

    # Act(실행): 테스트 대상의 처리를 실행한다
    result = add(a, b)

    # Assert(검증): 결과가 기대대로인지 확인한다
    assert result == 5


def test_add_returns_zero_when_adding_opposite_numbers():
    # Arrange
    a = -1
    b = 1

    # Act
    result = add(a, b)

    # Assert
    assert result == 0

💡 무엇이 달라졌나요?

  • 하나의 함수가 하나의 케이스만 테스트하게 되었다
  • 주석(# Arrange)으로 구조를 한눈에 알 수 있게 되었다
  • 테스트 함수명만 읽어도 「무엇을 테스트하는지」를 알 수 있다
  • 실패했을 때 어느 케이스가 망가졌는지 즉시 특정할 수 있다

각 페이즈의 상세와 작성법

① Arrange(준비):테스트의 전제 조건을 만든다

Arrange 페이즈에서는 테스트에 필요한 변수·객체·데이터를 준비합니다. 이 페이즈가 길어지는 경우는 pytest fixture로 꺼내면 정리됩니다.

def test_user_full_name():
    # Arrange: 테스트에 필요한 객체를 생성한다
    user = User(first_name="김", last_name="민준")

    # Act
    result = user.get_full_name()

    # Assert
    assert result == "김 민준"

⚠️ Arrange가 너무 길어질 때의 신호

Arrange가 10행을 넘는다면 테스트가 너무 복잡하거나, fixture로 꺼내야 한다는 신호입니다. 준비가 길면 테스트의 본질(무엇을 확인하고 싶은지)이 묻혀버립니다.

💡 AAA 패턴과 fixture의 관계

fixture는 AAA를 대체하는 것이 아니라, Arrange 부분을 여러 테스트에서 공통화하기 위한 구조입니다.

Arrange
→ fixture로 공통화
Act
테스트 대상을 실행
Assert
결과를 검증

② Act(실행):검증하고 싶은 처리를 실행한다

Act 페이즈에서는 검증하고 싶은 처리를 실행합니다. 이상적으로는 「하나의 목적에 집중한 조작」이지만, 실무에서는 로그인 후에 조작하거나 카트에 추가한 후 합계를 가져오는 등 여러 단계가 필요한 케이스도 보통 있습니다. 그런 경우에도 「이 테스트에서 무엇을 검증하고 싶은지」가 명확해지도록 의식합시다.

# ⚠️ Arrange와 Act가 혼재(구조를 알기 어렵다)
def test_bad_example():
    cart = ShoppingCart()
    cart.add_item("apple", price=100)
    cart.add_item("banana", price=150)
    cart.apply_discount(10)       # ← 할인을 테스트하는 건지? 합계만?
    result = cart.total()
    assert result == 225

# ✅ 무엇을 검증하는지를 명확하게 한 작성법
def test_total_is_correct_after_adding_two_items():
    # Arrange(전제 조건:카트에 2개의 상품이 들어 있다)
    cart = ShoppingCart()
    cart.add_item("apple", price=100)
    cart.add_item("banana", price=150)

    # Act(검증 대상:합계 금액 취득)
    result = cart.total()

    # Assert
    assert result == 250

③ Assert(검증):기댓값과 실제 결과를 비교한다

Assert 페이즈에서는 assert 문으로 결과를 검증합니다. 1개의 테스트 함수에 assert는 1~3개 정도가 기준입니다. pytest는 assert 문 실패 시 상세한 차이를 자동으로 표시해 주므로, 복잡한 assertion 라이브러리 없이도 심플하게 작성할 수 있습니다.

# ✅ 심플한 assert(권장)
assert result == 5
assert result is not None
assert "error" not in response_body
assert status_code in (200, 201)

# ✅ 에러 메시지 포함(실패 시 원인을 알기 쉽다)
assert result == 5, f"기댓값: 5, 실제 값: {result}"

# ❌ 1개의 함수에 assert를 너무 많이 넣음
def test_user():
    user = create_user("김민준", "minjun@example.com")
    assert user.name == "김민준"
    assert user.email == "minjun@example.com"
    assert user.is_active is True
    assert user.role == "user"
    assert user.created_at is not None  # 이것만으로 5개의 확인

💡 여러 assert가 필요한 경우는?

사용자 생성 후 「이름·이메일·권한·상태」를 함께 확인하고 싶은 경우는, 각각을 별도의 테스트 함수로 나누거나, 관련성이 강한 경우 2~3개 정도 묶는 것이 현실적입니다. 「실패했을 때 원인을 하나로 좁힐 수 있는가」를 기준으로 판단합시다.

읽기 쉬운 테스트 함수명 작성법

AAA 패턴과 마찬가지로 중요한 것이 테스트 함수명입니다. 함수명만 읽어도 「무엇을 테스트하는지」를 알 수 있으면, 실패 시 조사가 훨씬 빨라집니다.

권장 패턴:test_[무엇을]_[어떤 조건에서]_[어떤 결과가 나오는지]

나쁜 예좋은 예
test_login()test_login_with_valid_credentials_returns_200()
test_add()test_add_returns_correct_sum_with_positive_numbers()
test_error()test_login_with_wrong_password_raises_auth_error()
test_user_save()test_save_user_with_duplicate_email_raises_value_error()

길어 보일 수 있지만, 테스트 결과 목록에 나열되었을 때 함수명만으로 「무엇이 실패했는지」를 즉시 알 수 있습니다. 로그를 읽는 시간이 크게 줄어듭니다.

💡 여러 조건을 테스트하고 싶을 때는 parametrize가 편리

정상계·경계값·이상계 등 여러 케이스를 AAA 패턴으로 작성하면 함수가 늘어나지만, @pytest.mark.parametrize를 사용하면 하나의 함수로 여러 조건을 묶어서 검증할 수 있습니다.

import pytest

@pytest.mark.parametrize("a, b, expected", [
    (2,  3,   5),   # 정상계
    (0,  0,   0),   # 경계값
    (-1, 1,   0),   # 음수
])
def test_add_returns_correct_sum(a, b, expected):
    result = add(a, b)          # Act
    assert result == expected   # Assert

자세한 내용은 pytest 완전 가이드를 참고하세요.

실전:자주 있는 테스트를 AAA 패턴으로 작성하기

예①:계산 함수 테스트

def add(a: float, b: float) -> float:
    return a + b


def test_add_returns_correct_sum_with_positive_numbers():
    # Arrange
    a = 3
    b = 4

    # Act
    result = add(a, b)

    # Assert
    assert result == 7


def test_add_returns_negative_when_both_negative():
    # Arrange
    a = -3
    b = -4

    # Act
    result = add(a, b)

    # Assert
    assert result == -7

예②:예외가 발생하는 것을 테스트하기

import pytest


def divide(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("0으로 나눌 수 없습니다")
    return a / b


def test_divide_raises_value_error_when_divisor_is_zero():
    # Arrange
    a = 10
    b = 0

    # Act & Assert(예외 테스트는 Act와 Assert가 하나가 된다)
    with pytest.raises(ValueError, match="0으로 나눌 수 없습니다"):
        divide(a, b)

예③:클래스 메서드 테스트

class ShoppingCart:
    def __init__(self) -> None:
        self._items: list[dict] = []

    def add_item(self, name: str, price: int) -> None:
        self._items.append({"name": name, "price": price})

    def total(self) -> int:
        return sum(item["price"] for item in self._items)


def test_total_returns_sum_of_all_item_prices():
    # Arrange
    cart = ShoppingCart()
    cart.add_item("apple", price=100)
    cart.add_item("banana", price=200)

    # Act
    result = cart.total()

    # Assert
    assert result == 300


def test_total_returns_zero_when_cart_is_empty():
    # Arrange
    cart = ShoppingCart()

    # Act
    result = cart.total()

    # Assert
    assert result == 0

AAA 패턴에서 빠지기 쉬운 주의 포인트

🚧 주의 포인트 목록

① Act 페이즈에 여러 조작을 써버린다

Act에는 검증하고 싶은 처리만 씁니다. 준비 조작은 Arrange로 옮기는 것이 정답입니다. Act와 Arrange가 섞이면 이 테스트가 실제로 무엇을 확인하는지 알기 어려워집니다.

② Arrange 안에 assert를 써버린다

「준비 단계에서도 확인하고 싶다」는 마음은 이해하지만, Arrange 내의 assert는 테스트의 본론과 혼동됩니다. 확인은 반드시 Assert 페이즈에 집약합시다.

③ 테스트 함수명이 test_1, test_func 등 의미 없는 이름이 된다

CI/CD에서 테스트가 실패했을 때 함수명이 유일한 단서입니다. test_add_with_zero_returns_other_value처럼 「무엇을 테스트하는지」를 함수명에 쓰는 것이 중요합니다.

④ 하나의 테스트에 여러 조건을 묶어버린다

「정상계·이상계·경계값을 묶어서 하나의 테스트에서 확인하고 싶다」는 마음은 이해하지만, 각각 별도의 테스트 함수로 나눔으로써 어떤 조건이 실패했는지가 명확해집니다. @pytest.mark.parametrize를 사용하면 정리하기 쉽습니다.

⑤ 주석(# Arrange 등)을 생략한다

익숙해지면 주석을 생략하고 싶어지지만, 팀 개발에서는 주석이 있으면 다른 멤버가 구조를 빠르게 파악할 수 있습니다. 적어도 테스트에 익숙해질 때까지는 생략하지 않는 것이 안전합니다.

👨‍💻 실무의 코드 리뷰에서는 무엇을 보는가

필자가 테스트 코드를 리뷰할 때 가장 먼저 확인하는 것은 AAA 구조가 되어 있는지입니다. 「Arrange 없이 Act와 Assert만 있는 코드」나 「Arrange에 assert가 혼재된 코드」는 의도를 읽기 어렵고 이후 유지보수에서 혼란을 일으키기 쉽습니다. 테스트 코드는 「동작하면 된다」는 것만이 아니라, 팀의 공유 문서로서 작성한다는 의식이 장기적인 품질로 이어집니다.

자주 묻는 질문 (FAQ)

Q. AAA 패턴은 반드시 사용해야 하나요?

필수는 아니지만 팀 개발·장기적인 유지보수를 생각하면 채택하는 메리트가 큽니다. 소규모·개인의 스크립트적인 테스트에서는 생략하는 경우도 있습니다. 우선 의식해보는 것이 중요합니다.

Q. 예외 테스트에서 Act와 Assert가 하나가 되는 것은 문제인가요?

문제없습니다. pytest.raises()를 사용한 예외 테스트는 Act와 Assert가 하나가 되는 것이 자연스럽습니다. 이런 경우는 # Act & Assert라고 주석을 달면 의도가 전달됩니다.

Q. 1개의 테스트 함수에 assert를 몇 개까지 써도 되나요?

엄격한 규칙은 없지만 1~3개 정도가 기준입니다. 관련성이 강한 검증(예:레스포응의 상태 코드와 바디를 모두 확인한다)은 묶는 경우도 있습니다. 단, assert가 늘어날수록 「어느 것이 실패 원인인지」를 찾기 어려워집니다.

Q. AAA 패턴과 Given-When-Then의 차이는 무엇인가요?

개념은 거의 같습니다. Given-When-Then은 BDD(행동 주도 개발)에서 사용되는 표현으로, 보다 자연 언어에 가깝게 쓸 수 있습니다. 구조는 직접 대응합니다.

AAA 패턴Given-When-Then(BDD)의미
ArrangeGiven~라는 상태일 때
ActWhen~를 조작하면
AssertThen~라는 결과가 된다
Q. AAA 패턴과 TDD(테스트 주도 개발)는 관련이 있나요?

TDD는 「테스트를 먼저 작성하고 나서 코드를 구현하는 개발 수법」입니다. AAA 패턴은 테스트의 작성 방법의 구조이며, TDD와 조합해서 사용할 수 있습니다. TDD에서 테스트를 작성할 때도 AAA 패턴으로 구조화하면 가독성이 올라갑니다.

Q. AAA 패턴은 UI 테스트(Selenium·Playwright)에서도 사용할 수 있나요?

사용할 수 있습니다. UI 테스트에서는 Arrange에 드라이버 기동·로그인 처리 등을 쓰고, Act에 테스트하고 싶은 화면 조작(버튼 클릭·폼 입력 등)을 쓰고, Assert에 페이지의 타이틀이나 표시 내용 확인을 쓰는 것이 전형적인 패턴입니다.

Q. @pytest.mark.parametrize와 AAA 패턴은 함께 사용할 수 있나요?

사용할 수 있습니다. parametrize는 테스트 데이터를 외부에서 전달하는 구조입니다. @pytest.mark.parametrize로 데이터를 주면서 테스트 함수 내를 AAA 구조로 하면 「여러 조건을 정리하면서 읽기 쉬운 코드」를 작성할 수 있습니다.

Q. pytest의 fixture는 Arrange를 대체하나요?

네. 테스트 간에 공통되는 준비 처리(데이터베이스 연결·객체 생성 등)를 fixture로 꺼내면 Arrange 페이즈가 짧아지고 테스트의 본질이 보기 쉬워집니다. fixture는 Arrange의 「더 정리된 형태」라고 생각하세요. 자세한 내용은 pytest 완전 가이드를 참고하세요.

정리

  • AAA 패턴은 Arrange(준비)→ Act(실행)→ Assert(검증)의 3단 구조
  • 하나의 테스트 함수에서 하나의 목적·하나의 검증을 의식한다. 실무에서는 여러 단계가 필요한 케이스도 있다
  • 테스트 함수명은 test_[무엇을]_[어떤 조건에서]_[어떤 결과가 나오는지] 형태가 읽기 쉽다
  • Assert는 1~3개 정도가 기준·준비가 길어지면 pytest fixture로 꺼낸다
  • 여러 조건의 테스트는 @pytest.mark.parametrize로 정리할 수 있다

AAA 패턴은 「어려운 테크닉」이 아니라, 테스트 코드를 읽는 사람에 대한 배려입니다. 미래의 자신이나 팀 멤버가 「무엇을 테스트하는지」를 한눈에 이해할 수 있는 코드를 작성하는 것이, 유지보수하기 쉬운 테스트 스위트로의 첫 걸음입니다. 다음에 작성할 테스트 함수부터 AAA 패턴을 의식해보세요.

📚 함께 읽으면 좋은 관련 글

제목과 URL을 복사했습니다