Neoself의 기술 블로그

Unit Test로 찾아낸 Form Validation 오류와 해결과정 본문

개발지식 정리/Swift

Unit Test로 찾아낸 Form Validation 오류와 해결과정

Neoself 2024. 11. 28. 12:48

기존 TyTE 앱의 회원가입 플로우에서는 영어 username만을 사용할 수 있도록 하였는데요. MVP 모델 출시 이후, 한글 닉네임도 허용할 수 있게 해달라는 피드백을 반영해, 이번 1.1버전 업데이트를 통해 사용자 이름에 한글도 허용하도록 정책을 변경하게 되었습니다.

이를 위해 사용자 이름의 유효성을 판단하는 usernamePredicate를 수정하였는데, 이 변경이 회원가입 플로우에 영향을 주지 않는지 확인하기 위해 XCTest를 활용, 단위 테스트를 작성 및 검증해보고자 했습니다.

 

1. 테스트 설계 접근 방식

OnboardingView의 핵심기능인 회원가입 플로우를 검증하기 위해, 테스트가 필요한 핵심요소들을 정의했습니다.

 

1.1 검증 대상 정의

1. 필드별 유효성 검사

- Username

- Password

 

2. 예외 상황 처리

- 엣지케이스 검증

- 상태관리 검증

 

3. UI 플로우 검증

- 버튼 활성화 조건

- 에러 상태 표시

 

1.2 세부 검증 항목

1. Username 유효성 검사

- 길이 제한: 3-20자 제한 로직 동작 여부

- 상태 초기화: 입력값 변경 시 에러 상태 리셋 동작 여부

- 허용문자: 영문, 숫자, 언더스코어, 한글 지원 여부

 

2. Password 유효성 검사

- 길이 제한: 8자미만 제한 로직 동작 여부

- 에러 처리: 유효성 검증 실패 혹은 비밀번호 불일치 시 상태관리

 

3. 엣지케이스 검증

- 경계값: 최소 / 최대 길이 정확히 충족되는지 여부

- 금지 문자: 특수문자, 공백 등의 처리

- 특수 패턴: 숫자만으로 구성된 username 등

 

4. 전체 UI 플로우

- 버튼 상태: 각 필드의 유효성에 따른 활성화/비활성화

- 에러 표시: 실패 상황에서의 UI 업데이트

- 화면 전환: 로그인/회원가입 간 전환 시 상태 초기화

 

2. 테스트 케이스 설계

2.1 기초 셋업 작업

import XCTest
@testable import tyte
import Foundation
import Combine

final class AccountValidationTests: XCTestCase {
    var viewModel: AuthViewModel! // 온보딩뷰에서 사용되는 뷰모델
    var mockAuthService: MockAuthService! // 사용자 인증 관련 Mock 서비스 레이어
    private var cancellables: Set<AnyCancellable>!
    
    override func setUp() {
        super.setUp()
        mockAuthService = MockAuthService()
        cancellables = []
        viewModel = AuthViewModel(authService: mockAuthService, appState: AppState.shared)
    }
    
    override func tearDown() {
        viewModel = nil
        mockAuthService = nil
        cancellables = nil
        super.tearDown()
    }
    ...
}

현재 TyTE는 단일 책임 원칙을 준수하기 위해 사용자 인증로직은 AuthViewModel에, 그리고 로직간에 수행되는 네트워크 통신은 AuthService로 분리구성되어있습니다.

 

이번 XCTest의 경우, 네트워크통신 관련 로직은 검증대상에서 제외하고자 했습니다. 따라서 네트워크통신 대신 특정 값을 바로 반환하는 로직이 담긴 MockAuthService를 작성한 후, 이를 setUp() 메서드에서 생성해주었습니다.

 

setUp과 tearDown 메서드 구성 후, 정의된 검증 항목들을 바탕으로 다음과 같은 테스트 케이스들을 설계했습니다. 

 

2.1 Username 검증 테스트

// 기본 유효성 검사
func testValidUsername() {
    let validUsernames = ["user123", "neo_self", "test_user_123", "abc123", "username20"]
    for username in validUsernames {
        viewModel.username = username
        XCTAssertTrue(viewModel.usernamePredicate.evaluate(with: username))
        XCTAssertFalse(viewModel.isUsernameInvalid)
    }
}
// 최소 길이 제한
func testUsernameTooShort() {
    let shortUsernames = ["a", "ab", ""]
    for username in shortUsernames {
        viewModel.username = username
        XCTAssertFalse(viewModel.usernamePredicate.evaluate(with: username))
    }
}
// 최대 길이 제한
func testUsernameTooLong() {
    let longUsername = "verylongusernamethatismorethan20characters"
    viewModel.username = longUsername
    XCTAssertFalse(viewModel.usernamePredicate.evaluate(with: longUsername))
}
// 한글 지원
func testKoreanUsername() {
    let validUsernames = ["홍길동", "사용자123", "테스트_user", "ㄱㄴㄷ123", "한글이름2024"]
    for username in validUsernames {
        viewModel.username = username
        XCTAssertTrue(viewModel.usernamePredicate.evaluate(with: username))
    }
}

각 검증 항목마다, 검증테스트 케이스들을 배열로 정리한 후, for문으로 이 배열을 순회, viewModel의 username 속성에 주입시켜준 후, userPredicate.evaluate 반환값을 XCTAssert 메서드를 통해 확인하는 플로우로 일괄 구성해주었습니다.

 

2.2 Password 검증 테스트

// 기본 유효성 검사
func testValidPasswords() {
    let validPasswords = ["password123", "longpassword1234567890", "Pass1234!@#$", "한글비밀번호123", "12345678", "        "]
    for password in validPasswords {
        viewModel.password = password
        XCTAssertTrue(viewModel.passwordPredicate.evaluate(with: password), "Password '\(password)' should be valid")
        XCTAssertFalse(viewModel.isPasswordInvalid, "Password '\(password)' should not set isPasswordInvalid")
    }
}
// 최소 길이 제한
func testPasswordTooShort() {
    let shortPasswords = ["", "1234567", "short", "abc"]
    for password in shortPasswords {
        viewModel.password = password
        XCTAssertFalse(viewModel.passwordPredicate.evaluate(with: password), "Password '\(password)' should be invalid - too short")
    }
}
// 상태 초기화
func testPasswordStateReset() {
    viewModel.password = "short"
    viewModel.isPasswordInvalid = true
    viewModel.isPasswordWrong = true
    viewModel.password = "newpassword123"
    XCTAssertFalse(viewModel.isPasswordInvalid, "isPasswordInvalid should be reset when password changes")
    XCTAssertFalse(viewModel.isPasswordWrong, "isPasswordWrong should be reset when password changes")
}

비밀번호에 대한 검증 케이스들도 username과 유사하게 최소길이 제한, predicate를 바탕으로 하는 기본 유효성 검사를 진행해주었습니다. Username과 다른점이 하나 있다면, viewModel 내부에서의 상태 초기화 로직에 관해 추가 테스트를 진행하였다는 것인데요.

이는 비밀번호가 불일치하거나 유효하지 않아 필드 배경이 빨간색이 된 상태에서 비밀번호필드를 추가 조작하면 필드색상이 초기화되는지 여부를 검증하기 위합니다.

 

2.3 엣지케이스 테스트

func testUsernameEdgeCases() {
    let edgeCases = ["123", "aaa", "12345678901234567890", "___", "a_1"]
    for username in edgeCases {
        viewModel.username = username
        XCTAssertTrue(viewModel.usernamePredicate.evaluate(with: username), "Username '\(username)' should be valid")
    }
}

그 다음으로 Username 필드에 대한 엣지케이스를 배열로 담아 일괄 테스트해주었습니다. 특히 3-20자 길이 제한조건이 제대로 적용되었는지 테스트하기 위해 정확히 3자의 username과 20자의 username이 정상통과되는지에 주목했습니다.

 

2.4 UI 플로우 테스트

// 회원가입 버튼 상태
func testSignUpButtonStateWithUsername() {
    viewModel.email = "test@example.com"
    viewModel.password = "password123"
    viewModel.username = "a"
    XCTAssertTrue(viewModel.isSignUpButtonDisabled)
    viewModel.username = "validuser123"
    XCTAssertFalse(viewModel.isSignUpButtonDisabled)
}
func testSignUpButtonStateWithPassword() {
    viewModel.username = "validuser"
    viewModel.email = "test@example.com"
    viewModel.password = "short"
    XCTAssertTrue(viewModel.isSignUpButtonDisabled)
    viewModel.password = "validpassword123"
    XCTAssertFalse(viewModel.isSignUpButtonDisabled)
}
// 로그인 버튼 상태
func testLoginButtonStateWithPassword() {
    viewModel.email = "test@example.com"
    viewModel.isExistingUser = true
    viewModel.password = ""
    XCTAssertTrue(viewModel.isButtonDisabled)
    viewModel.password = "password123"
    XCTAssertFalse(viewModel.isButtonDisabled)
    viewModel.isPasswordWrong = true
    XCTAssertTrue(viewModel.isButtonDisabled)
}

또한 회원가입 그리고 로그인 버튼의 활성화/비활성화 상태를 검증하기 위한 UI 플로우 테스트를 구현했습니다. 이때 주목할 점은 버튼의 상태가 단순히 필드의 값이 있는지 여부뿐만 아니라, 각 값의 유효성에도 영향을 받아야 한다는 것입니다. 예를 들어 비밀번호가 8자 미만일 때는 다른 필드가 모두 유효하더라도 회원가입 버튼이 비활성화되어야 했으며, 로그인 실패 후 isPasswordWrong 상태가 true로 변경되었을 때도 버튼이 비활성화되어야 합니다.

이러한 복잡한 상태 조건들이 올바르게 작동하는지 검증하기 위해 다양한 시나리오를 테스트케이스로 구현했습니다.

 

 

3. 발견된 문제점

위와 같이 테스트케이스를 설계하고 Test를 진행한 결과, 아래 테스트케이스가 통과되지 않았는데요. 두 메서드 모두 회원가입 플로우에 사용되는 최종 회원가입 버튼의 활성화 조건을 검증하는 테스트 메서드입니다.

// 수정 전: 잘못된 구현
var isSignUpButtonDisabled: Bool {
    username.isEmpty || 
    password.isEmpty || 
    isEmailInvalid ||   // 불필요한 조건
    isPasswordInvalid || 
    isLoading 
}

해당 활성화조건 코드를 분석해본 결과, 회원가입 활성화 조건이 username이 아닌 email값을 바라보고 있던 것이 문제였습니다. 로그인 플로우의 경우, 이메일값과 비밀번호값을 검사해야하지만, 회원가입 플로우는 이메일의 유효성 검증이 완료된 이후에 진행되며, 닉네임과 비밀번호값을 검사해야합니다.

즉 회원가입 버튼 활성화 여부 검사 시에는, Email 유효성 검증이 불필요했으며, 정작 사용자가 실제로 입력하는 닉네임 필드는 검증대상에 포함되어있지 않았기 때문에, 테스트케이스가 통과되지 못하는 거였습니다.

 

4. 해결방안

회원가입 플로우에서는 이메일 유효성 검사가 이미 완료된 상태이므로, 해당 조건을 제거하고 유저네임 유효성 검사를 추가했습니다.

// 수정 후: 올바른 구현
var isSignUpButtonDisabled: Bool {
    username.isEmpty || 
    password.isEmpty || 
    isPasswordInvalid || 
    isLoading ||
    !isUsernameValid // 추가
}

 

그 결과, 아래와 같이 설계했던 모든 테스트케이스들을 통과시킬 수 있었습니다.

5. 결론

위와 같은 테스트 설계 과정을 통해 아래와 같은 이점을 얻을 수 있었습니다.

 

1. 검증이 필요한 항목들을 명확히 정의할 수 있었습니다.

2. 누락된 테스트 케이스를 방지할 수 있었습니다.

3. 예상치 못한 버그를 발견하고 수정할 수 있었습니다.

 

이상으로 단위 테스트 관련 블로그 글을 마치겠습니다.

감사합니다.