PHpullh
언어 비교/테스트

LANGUAGE COMPARISON

테스트를 언어별로 비교하기

테스트 러너가 언어에 내장된 쪽과 외부 프레임워크를 고르는 쪽을 비교합니다.

도구를 고르는 단계가 있는가

테스트를 시작하는 경험은 두 갈래로 갈립니다. Go와 Rust는 테스트 러너가 언어 도구에 들어 있어서 go test, cargo test만 치면 끝입니다. 무엇을 쓸지 고르는 단계가 아예 없습니다.

Java, C#, JavaScript, Python은 외부 프레임워크를 고릅니다. 선택지가 많아 상황에 맞출 수 있지만, 시작할 때 결정할 것이 하나 늘고 예제마다 문법이 달라 검색 결과가 섞입니다. 처음 배우는 사람에게는 이 단계가 생각보다 큰 장벽입니다.

테스트 코드를 어디에 두는지도 갈립니다. Rust는 소스 파일 안에 테스트 모듈을 함께 두는 것이 관행이라 비공개 함수도 그대로 검증할 수 있습니다. Java와 C#은 별도 디렉터리에 두어 공개 API 위주로 테스트하게 되고, Go는 같은 패키지 안에 _test.go 파일을 둬서 둘의 중간쯤입니다.

Kotlin

JUnit5 + Kotest 기초

Kotlin 친화적인 테스트 프레임워크로 읽기 쉬운 테스트를 작성합니다.

// build.gradle.kts 의존성:
// testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
// testImplementation("io.kotest:kotest-assertions-core:5.8.0")

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldThrow

class CalculatorTest : DescribeSpec({
    val calc = Calculator()

    describe("덧셈") {
        it("양수 + 양수") {
            calc.add(2, 3) shouldBe 5
        }
        it("음수 포함") {
            calc.add(-1, 1) shouldBe 0
        }
    }

    describe("나눗셈") {
        it("정상 나눗셈") {
            calc.divide(10, 2) shouldBe 5.0
        }
        it("0으로 나누면 예외") {
            shouldThrow<ArithmeticException> {
                calc.divide(10, 0)
            }
        }
    }
})

// JUnit5 스타일
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class CalcJUnit {
    @Test
    fun `두 수의 합`() {   // 백틱으로 한글 테스트명 가능
        assertEquals(5, Calculator().add(2, 3))
    }
}

class Calculator {
    fun add(a: Int, b: Int) = a + b
    fun divide(a: Int, b: Int): Double {
        if (b == 0) throw ArithmeticException("0으로 나눌 수 없음")
        return a.toDouble() / b
    }
}

Python

pytest — 기초 & fixture & parametrize

Python 최고의 테스트 프레임워크 pytest로 효율적인 테스트를 작성합니다.

# test_calculator.py
import pytest
from calculator import add, divide

# 기본 테스트
def test_add_positive():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, 1) == 0

# 예외 테스트
def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError, match="division by zero"):
        divide(10, 0)

# @pytest.mark.parametrize — 여러 케이스 자동 실행
@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
    (100, -50, 50),
])
def test_add_cases(a, b, expected):
    assert add(a, b) == expected

# fixture — 테스트 전후 설정/정리
@pytest.fixture
def sample_data():
    data = {"users": ["Alice", "Bob"], "count": 2}
    yield data          # 여기서 테스트 실행됨
    # yield 이후는 teardown
    print("\n정리 완료")

def test_with_fixture(sample_data):
    assert sample_data["count"] == 2
    assert "Alice" in sample_data["users"]

# tmp_path fixture (pytest 내장)
def test_file_write(tmp_path):
    file = tmp_path / "test.txt"
    file.write_text("hello")
    assert file.read_text() == "hello"

# monkeypatch — 의존성 교체
def test_with_mock(monkeypatch):
    monkeypatch.setattr("builtins.input", lambda _: "42")
    # input()이 항상 "42"를 반환

Go

testify & Mock 테스트

외부 라이브러리 testify로 더 읽기 쉬운 테스트를 작성합니다.

// go get github.com/stretchr/testify
package main

import (
	"testing"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/mock"
)

// 인터페이스 정의
type UserRepo interface {
	FindByID(id int) (*User, error)
	Save(u *User) error
}

// Mock 구현
type MockUserRepo struct {
	mock.Mock
}

func (m *MockUserRepo) FindByID(id int) (*User, error) {
	args := m.Called(id)
	return args.Get(0).(*User), args.Error(1)
}

func (m *MockUserRepo) Save(u *User) error {
	return m.Called(u).Error(0)
}

// 서비스
type UserService struct{ repo UserRepo }

func (s *UserService) GetUser(id int) (*User, error) {
	return s.repo.FindByID(id)
}

// 테스트
func TestGetUser(t *testing.T) {
	mockRepo := new(MockUserRepo)
	expected := &User{ID: 1, Name: "Alice"}

	// Mock 설정
	mockRepo.On("FindByID", 1).Return(expected, nil)

	svc := &UserService{repo: mockRepo}
	user, err := svc.GetUser(1)

	// assert — 실패해도 계속
	assert.NoError(t, err)
	assert.Equal(t, expected.Name, user.Name)
	assert.NotNil(t, user)

	// require — 실패 시 즉시 중단
	require.NotNil(t, user)

	// Mock 호출 검증
	mockRepo.AssertExpectations(t)
	mockRepo.AssertCalled(t, "FindByID", 1)
}

Java

TestContainers & 통합 테스트

Docker 컨테이너로 실제 DB/서비스를 띄워 통합 테스트를 작성합니다.

import org.junit.jupiter.api.*;
import org.testcontainers.containers.*;
import org.testcontainers.junit.jupiter.*;
import org.testcontainers.utility.DockerImageName;
import java.sql.*;

// TestContainers — 실제 PostgreSQL 컨테이너
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserRepositoryIT {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>(DockerImageName.parse("postgres:15"))
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    Connection connection;

    @BeforeAll
    void setup() throws SQLException {
        connection = DriverManager.getConnection(
            postgres.getJdbcUrl(),
            postgres.getUsername(),
            postgres.getPassword());

        // 스키마 생성
        connection.createStatement().execute("""
            CREATE TABLE users (
                id SERIAL PRIMARY KEY,
                name VARCHAR(100) NOT NULL,
                email VARCHAR(255) UNIQUE
            )
            """);
    }

    @Test
    @DisplayName("사용자 저장 & 조회")
    void saveAndFind() throws SQLException {
        // Given
        var insert = connection.prepareStatement(
            "INSERT INTO users (name, email) VALUES (?, ?)");
        insert.setString(1, "Alice");
        insert.setString(2, "alice@test.com");
        insert.executeUpdate();

        // When
        var select = connection.prepareStatement(
            "SELECT * FROM users WHERE email = ?");
        select.setString(1, "alice@test.com");
        var rs = select.executeQuery();

        // Then
        assertTrue(rs.next());
        assertEquals("Alice", rs.getString("name"));
    }

    @AfterAll
    void teardown() throws SQLException {
        if (connection != null) connection.close();
    }
}

// Redis 컨테이너 예시:
// @Container
// static GenericContainer<?> redis =
//     new GenericContainer<>("redis:7")
//         .withExposedPorts(6379);

PHP

PHPUnit 기본 테스트

PHPUnit의 기본 테스트 클래스와 assertion 메서드를 보는 예제입니다.

<?php
use PHPUnit\Framework\TestCase;

final class SluggerTest extends TestCase {
    public function testItCreatesSlug(): void {
        $this->assertSame('hello-php', str_replace(' ', '-', strtolower('Hello PHP')));
    }
}

JavaScript

동등성 단언

1 + 1 <= 1이 거짓이므로 예외 없이 지나가고 True가 출력됩니다. 테스트 프레임워크 없이 조건을 확인할 때는 이렇게 예외를 던지는 방식이 가장 단순합니다. 다만 Exception을 그대로 던지면 기대값과 실제값이 메시지에 남지 않아, 실패했을 때 무엇이 달랐는지 알 수 없습니다.

// 동등성 단언
if (1 + 1 <= 1) throw new Error("assert failed");

TypeScript

동등성 단언

1 + 1 <= 1이 거짓이므로 예외 없이 지나가고 True가 출력됩니다. 테스트 프레임워크 없이 조건을 확인할 때는 이렇게 예외를 던지는 방식이 가장 단순합니다. 다만 Exception을 그대로 던지면 기대값과 실제값이 메시지에 남지 않아, 실패했을 때 무엇이 달랐는지 알 수 없습니다.

// 동등성 단언
if (1 + 1 <= 1) throw new Error("assert failed");

C#

동등성 단언

1 + 1 <= 1이 거짓이므로 예외 없이 지나가고 True가 출력됩니다. 테스트 프레임워크 없이 조건을 확인할 때는 이렇게 예외를 던지는 방식이 가장 단순합니다. 다만 Exception을 그대로 던지면 기대값과 실제값이 메시지에 남지 않아, 실패했을 때 무엇이 달랐는지 알 수 없습니다.

// 동등성 단언
using System;

if (1 + 1 <= 1) throw new Exception("assert failed");
Console.WriteLine(true);

C++

동등성 단언

1 + 1 <= 1이 거짓이므로 예외 없이 지나가고 True가 출력됩니다. 테스트 프레임워크 없이 조건을 확인할 때는 이렇게 예외를 던지는 방식이 가장 단순합니다. 다만 Exception을 그대로 던지면 기대값과 실제값이 메시지에 남지 않아, 실패했을 때 무엇이 달랐는지 알 수 없습니다.

// 동등성 단언
#include <cassert>

int main() {
    assert(1 + 1 > 1);
}

Rust

동등성 단언

1 + 1 <= 1이 거짓이므로 예외 없이 지나가고 True가 출력됩니다. 테스트 프레임워크 없이 조건을 확인할 때는 이렇게 예외를 던지는 방식이 가장 단순합니다. 다만 Exception을 그대로 던지면 기대값과 실제값이 메시지에 남지 않아, 실패했을 때 무엇이 달랐는지 알 수 없습니다.

// 동등성 단언
fn main() {
    assert!(1 + 1 > 1);
}

목 객체를 어떻게 만드는가

외부 의존성을 가짜로 바꿔치는 방식이 언어의 성격을 드러냅니다. Java와 C#은 런타임에 프록시를 만들어 주는 목 프레임워크가 발달했습니다. Python은 동적 타이핑을 활용해 속성을 통째로 갈아 끼우는 방식이 흔합니다.

Go와 Rust에는 그런 프레임워크가 표준처럼 자리 잡지 않았습니다. 대신 인터페이스나 트레이트로 경계를 긋고 테스트용 구현을 직접 만드는 방식이 관행입니다. 코드는 조금 늘지만 무엇을 대체했는지가 코드에 그대로 보이고, 마법 같은 동작이 없어 추적이 쉽습니다.

어느 쪽이든 원칙은 같습니다. 바꿔치기가 어렵다면 대개 테스트 도구의 문제가 아니라 의존성이 코드에 박혀 있다는 설계 신호입니다.

병렬 실행이 만드는 간헐적 실패

Rust의 cargo test와 Go의 패키지 단위 테스트는 기본적으로 병렬로 돕니다. 각각은 통과하는데 함께 돌리면 가끔 실패한다면 공유 자원이 원인일 가능성이 큽니다. 같은 임시 파일 이름, 환경 변수 변경, 전역 상태, 고정 포트가 단골입니다.

해결은 격리입니다. 임시 디렉터리를 테스트마다 새로 만들고, 포트는 0번으로 열어 OS가 배정하게 하고, 전역 설정을 바꾸는 테스트는 직렬로 돌리도록 표시하십시오. "가끔 실패하는 테스트"를 재실행으로 넘기기 시작하면 신뢰가 무너집니다.

같은 입력 묶음을 반복하는 법

입력만 다르고 검증이 같은 테스트를 줄이는 장치는 어느 언어에나 있습니다. Go의 테이블 기반 테스트, JUnit의 @ParameterizedTest, pytest의 parametrize, xUnit의 [Theory]가 같은 목적입니다.

이 형태로 옮기면 경계값을 추가하기가 쉬워집니다. 빈 입력, 원소 하나, 최댓값, 음수처럼 잊기 쉬운 케이스를 표에 한 줄씩 더하면 되니까요. 테스트 개수보다 표에 빠진 줄이 무엇인지를 보는 습관이 실제 결함을 잡습니다.