[Swift] - UnitTest 예제(Given-When-Then 패턴)
UnitTest를 공부하던 중 test코드를 어떻게 작성해야하는지 감이 안 잡혔다. 코드의 구조를 명확하게 파악할 수 있도록 구현하고 싶은데 이 부분이 어려웠다. 그러던 중 테스트 코드를 표현하는 방식인 Given-When-Then 패턴에 대해 접하게 되었다.
이 패턴은 "어떤 상황에서 특정 동작을 수행하면 예상되는 결과는 무엇인가?"라는 방식으로 분리하여, 테스트 코드를 작성하도록 돕는다고 한다.
이번 포스팅에서 Given-When-Then 패턴이 무엇인지 이해하고 이해한 걸 바탕으로 간단한 Unit Test 예제에 적용해보자.
1. Given-When-Then
Given-When-Then은 테스트 코드의 논리적인 흐름을 더 명확하게 표현하는 방법이다.
1️⃣ Given: 초기 상태 설정 (테스트 준비)
- 테스트를 수행하기 전에 필요한 사전 조건이나 초기 상태를 설정
- 테스트를 위한 객체를 생성하거나, 필요한 변수를 초기화하는 단계
- 테스트가 시작될 때 어떤 상황인지를 설명
예시: username과 email이 미리 정의되어 있는 상태, 그리고 userManager가 초기화되어 있는 상태.
2️⃣ When: 동작 수행 (테스트 실행)
- 테스트의 핵심 동작을 수행하는 단계
- 테스트하려는 메서드나 기능을 실행
- "주어진 상황에서 특정 동작이 일어났을 때" 어떤 일이 발생하는지를 표현
예시: userManager.addUser(username: email:) 메서드를 호출하여 사용자를 추가하는 동작을 수행하는 단계.
3️⃣ Then: 결과 검증 (테스트 확인)
- When 단계에서 수행된 동작의 결과를 검증하는 단계
- 예상한 결과와 실제 결과를 비교하여 테스트의 성공 여부를 확인
- 주로 XCTAssert와 같은 검증 메서드를 사용
예시: newUser의 id, username, email이 예상한 값과 일치하는지 확인하고, 사용자가 제대로 추가되었는지 getUser를 통해 검증하는 단계.
그럼 왜 Given-When-Then 패턴으로 코드를 작성하는게 좋을까??
이 부분은 아래에서 설명하겠다.
2. Unit Test 예제
Unit Test를 하기 위해 시나리오 3가지를 정리해봤다.
테스트 코드를 작성하기 전 필요한 코드는 다음과 같다.
struct User {
let id: Int
let username: String
let email: String
}
class UserManager {
private var users: [User] = []
func addUser(username: String, email: String) throws -> User {
guard isValidEmail(email) else {
throw UserError.invalidEmail
}
guard !userExists(username: username) else {
throw UserError.userAlreadyExists
}
let newUser = User(id: users.count + 1, username: username, email: email)
users.append(newUser)
return newUser
}
func getUser(byUsername username: String) -> User? {
return users.first { $0.username == username }
}
private func isValidEmail(_ email: String) -> Bool {
// 간단한 이메일 유효성 검사
return email.contains("@") && email.contains(".")
}
private func userExists(username: String) -> Bool {
return users.contains { $0.username == username }
}
}
enum UserError: Error {
case invalidEmail
case userAlreadyExists
}
시나리오 3가지 중 첫번째 시나리오를 구현해보자.
시나리오 1: 유효한 사용자 추가
시나리오 1: 유효한 사용자 추가
사전 조건:
- UserManager 인스턴스가 생성되어 있음
- 사용자 목록이 비어 있음
인수 조건:
- username: 빈 문자열이 아닌 유효한 문자열
- email: '@'와 '.'을 포함한 유효한 이메일 형식의 문자열
예상 결과:
- 새로운 User 객체가 반환됨
- 반환된 User 객체의 id가 1임
- 사용자 목록에 새로운 사용자가 추가됨
처음에 Given-When-Then 패턴을 모르는 상태에서 무작정 시나리오를 보고 코드를 작성한 결과 아래와 같다.
func testAddValidUser() {
do {
let user = try userManager?.addUser(username: "이름", email: "abcd@gmail.com")
if let user{
XCTAssertEqual(user.id, 1)
XCTAssertEqual(user.username, "이름")
XCTAssertEqual(user.email, "abcd@gmail.com")
let retrievedUser = userManager.getUser(byUsername: username)
XCTAssertNotNil(retrievedUser)
XCTAssertEqual(retrievedUser?.id, user.id)
}
} catch {
XCTFail("error: \(error)")
}
}
이 코드는 username과 email을 변수로 선언하지 않고 문자열을 직접 사용해서 유지보수성이 떨어질 수 있다는 걸 알 수 있다. 또한, 코드의 구조가 명확하지 않아 테스트의 시작점, 동작 코드, 결과를 파악하기 어렵다는 문제도 있다.
솔직히 작성한 내가 봐도 어떤 기능을 테스트하고 싶은지 원하는 결과는 무엇인지를 판단하기 어렵다... 작성한 사람도 코드의 흐름을 판단하기 어렵다는 건 다른 사람들은 더더욱 이해하기 어렵겠지,,,
이러한 문제를 해결하기 위해 다음과 같이 Given-When-Then 패턴을 적용해서 코드의 구조를 체계화하도록 했다.
코드의 구조가 명확하다면 코드의 흐름을 파악하는데 도움이 될 것이다.
- Given: 사용자 이름과 유효한 이메일 설정
- When: 사용자 추가 메소드 호출 및 에러 처리
- Then: 추가된 사용자의 정보가 예상한 값과 일치하는지 확인하고, getUser 메소드를 통해 해당 사용자가 정상적으로 저장되었는지 확인
func testAddValidUser() {
// Given
let username = "testuser"
let email = "test@example.com"
// When
let newUser: User
do {
// 유효한 사용자 추가를 시도하고, 성공 시 newUser에 저장
newUser = try userManager.addUser(username: username, email: email)
} catch {
// 오류 발생 시 테스트 실패로 처리하고, 해당 에러 메시지를 출력
XCTFail("Valid user addition threw an error: \(error)")
return
}
// Then
XCTAssertEqual(newUser.id, 1)
XCTAssertEqual(newUser.username, username)
XCTAssertEqual(newUser.email, email)
// 추가된 사용자가 userManager에서 정상적으로 검색되는지 확인
let retrievedUser = userManager.getUser(byUsername: username)
XCTAssertNotNil(retrievedUser) // 사용자가 정상적으로 검색되었는지 확인
XCTAssertEqual(retrievedUser?.id, newUser.id) // 검색된 사용자의 ID가 일치하는지 확인
}
같은 기능을 하는 코드지만 코드의 구조에 따라서 가독성이 훨씬 좋아진 걸 확인할 수 있다!!
Given-When-Then 패턴이 뭐가 중요하지?? 그냥 당연한거 아닌가?? 라고 생각하면서 대수롭지 않게 생각하며 바로 코드를 작성했지만, 실제로 코드를 작성해보니 코드의 구조가 얼마나 중요한지 깨달을 수 있었다.
Given-When-Then의 장점
- 가독성: 테스트의 구조가 명확해져 코드만 봐도 테스트가 무엇을 하는지 쉽게 이해할 수 있다.
- 유지보수성: 테스트의 의도를 명확하게 표현하므로, 테스트 케이스를 유지하거나 수정할 때 도움이 된다.
- 일관성: 테스트 코드를 작성할 때 일관된 패턴을 유지하여 팀원들과의 협업이 원활해진다.
시나리오 2: 잘못된 이메일로 사용자 추가 시도
이제 Given-When-Then을 왜 사용하는지 알았으니 남은 시나리오도 다 구현해보자.
이번에는 두번째~~
시나리오 2: 잘못된 이메일로 사용자 추가 시도
사전 조건:
- UserManager 인스턴스가 생성되어 있음
인수 조건:
- username: 빈 문자열이 아닌 유효한 문자열
- email: '@' 또는 '.'이 없는 잘못된 형식의 문자열
예상 결과:
- UserError.invalidEmail 에러가 발생함
- 사용자 목록에 변화가 없음
- Given: 잘못된 이메일을 가진 사용자 이름과 이메일을 설정
- When: userManager.addUser 메소드를 호출할 때 오류가 발생하는지 확인
- Then: 발생한 오류가 UserError.invalidEmail과 일치하는지 검증하고, 사용자가 저장되지 않았음을 확인하기 위해 getUser 메소드를 호출하여 반환 값이 nil인지 확인
func testAddUserWithInvalidEmail() {
// Given
let username = "testuser"
let invalidEmail = "invalidemail" // 이메일 형식이 유효하지 않음
// When
var thrownError: Error?
XCTAssertThrowsError(try userManager.addUser(username: username, email: invalidEmail)) {
thrownError = $0
}
// Then
XCTAssertEqual(thrownError as? UserError, UserError.invalidEmail)
// 사용자 추가가 실패했으므로 userManager에서 사용자를 찾을 수 없어야 함
let retrievedUser = userManager.getUser(byUsername: username)
XCTAssertNil(retrievedUser) // 사용자가 저장되지 않았음을 확인
}
시나리오 3: 중복된 사용자명으로 추가 시도
마지막 세번째~~
시나리오 3: 중복된 사용자명으로 추가 시도
사전 조건:
- UserManager 인스턴스가 생성되어 있음
- "testuser"라는 이름의 사용자가 이미 존재함
인수 조건:
- username: "testuser" (이미 존재하는 사용자명)
- email: 유효한 이메일 형식의 문자열
예상 결과:
- UserError.userAlreadyExists 에러가 발생함
- 사용자 목록에 변화가 없음
- Given: 사용자 이름과 이메일 설정
- When: 중복된 사용자 이름으로 다른 이메일로 추가를 시도하고, 오류가 발생하는지 확인
- Then: 발생한 오류가 기대한 UserError.userAlreadyExists인지 확인하고, 추가된 사용자가 여전히 존재하며 그 사용자의 이메일이 원래 입력한 값과 일치하는지 확인
func testAddDuplicateUser() throws {
// Given
let username = "testuser"
let email = "test@example.com"// 첫 번째 사용자의 이메일
let duplicateEmail = "another@example.com"// 중복 시도에서 사용할 이메일
// 첫 번째 사용자 추가 시도
let newUser = try userManager.addUser(username: username, email: email)
// When
var thrownError: Error?
XCTAssertThrowsError(try userManager.addUser(username: username, email: duplicateEmail)) {
thrownError = $0
}
// Then
XCTAssertEqual(thrownError as? UserError, UserError.userAlreadyExists)
// 추가된 사용자가 여전히 userManager에 있는지 확인
let retrievedUser = userManager.getUser(byUsername: username)
XCTAssertNotNil(retrievedUser)
XCTAssertEqual(retrievedUser?.email, email)
}
given-when-then 패턴을 알기 전에는 테스트 코드 작성이 막연하고, 어떤 방식으로 테스트 코드를 구현해야 하는지 감을 잡기 어려웠다.하지만 이 패턴을 접하면서 테스트 코드의 구조를 명확하게 잡은 상태에서 구현하다보니, 테스트의 각 단계를 구분할 수 있었고 이해하는데 도움이 되었다.
테스트 코드가 정리된 느낌을 가지다보니 다른 사람이 내가 구현한 코드를 봐도 코드의 흐름을 더 쉽게 전달할 수 있으니 협업에서 좋을 것 같다.~~~