본문 바로가기

프로젝트/오디

인수 테스트로 사용자 유즈 케이스 파악하기

Situation : 2차 UT에서 발생한 예상치 못한 에러

 

프로젝트 오디에서는 10.10 - 10.24일 간 20명의 패널을 대상으로 사용자 유저 경험을 수집했습니다. 이 중 가장 많은 피드백을 받았던 것 중 하나가 바로 약속방을 나가는 기능을 만들어달라는 것이었습니다.

 

이에 2차 UT 이전까지 약속방을 나갈 수 있는 기능을 개발하기로 결정하였고, 구현부터 테스트 코드까지 코드 리뷰를 통해 검증된 코드를 merge 하였습니다.

 

그러나 2차 UT 당일 날, UT 패널인 비토로부터 약속에서 나간 이후 동일 약속에 참여가 되지 않는다는 문자가 왔습니다.

 

이에 로그를 살펴보니 약속 참여원인 Mate에 회원 아이디(member_id)와 약속 아이디(meeting_id)를 기준으로 구성한 복합 unique 조건에 대해 SqlException이 발생한다는 사실을 알 수 있었습니다.

 

 

동일 회원이 나갔던 동일 약속에 참여를 시도할 경우 같은 회원-약속 아이디를 생성하려는 시도가 복합 유니크 키 조건을 위배하게 된 것이 문제였습니다.

 

조금 더 풀어 문제의 원인을 설명해보겠습니다.

현재 약속 참여원 Mate의 경우 회원 아이디 - 약속 아이디 유니크 조건이 걸려져 있습니다.

 

이 상황에서 먼저 member_id 2를 가진 회원이 meeting_id가 1인 약속에 참여했습니다.

그럼 위와 같이 DB에는 mate에 대한 새로운 컬럼이 추가됩니다.

다음으로 2번 mate가 이번에는 약속을 나갔습니다.

그럼 2번 mate의 deleted_at이 삭제된 시점으로 갱신되게 됩니다.

이제 다시 약속에 참여를 시도해보겠습니다.

서버는 2번 회원이 1번 약속에 참여하도록 시도하므로 meeting_id가 1이고 member_id가 2인 mate 열을 추가하려 할 것입니다. 그러나 mate에는 meeting_id와 member_id로 복합 유니크 조건이 걸려있고, 이전에 소프트 삭제한 mate 열이 남아있기에 SqlException이 발생했던 것입니다.

 

이에 유니크 조건을 member_id, meeting_id, deleted_at 이 3가지 복합키로 수정하여 같은 회원이 나갔던 약속의 재참여를 가능하도록 하였습니다.

 

@Table(uniqueConstraints = {
        @UniqueConstraint(
                name = "uniqueMeetingAndMemberAndDeletedAt",
                columnNames = {"meeting_id", "member_id", "deleted_at"}
        )
})
@Entity
@Getter
@SQLDelete(sql = "UPDATE mate SET deleted_at = NOW() WHERE id = ?")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Mate {
     ...중략...
}

 

그러나, 이런 의문이 남았습니다. 왜 테스트 코드까지 있었는데 이런 문제를 발견하지 못했을까?


Task : 유즈 케이스 시나리오를 테스트하기

 

작성했던 테스트 코드가 문제를 발견하지 못했던 이유는 무엇이었을까요? 테스트 코드를 살펴보니 회원이 방에서 나갈 수 있음을 검증하고 있었습니다.  그리고 이 테스트는 통과하여 단순 방을 나가는 기능은 잘 구현이 되었음을 의미합니다.

@DisplayName("약속에 참여하고 있는 mate를 약속 방에서 삭제한다.")
@Test
void deleteMateByMeetingIdAndMemberId() {
    Meeting meeting = fixtureGenerator.generateMeeting();
    Member kaki = fixtureGenerator.generateMember("kaki");
    fixtureGenerator.generateMate(meeting, kaki);

    mateService.leaveByMeetingIdAndMemberId(meeting.getId(), kaki.getId());

    assertThatThrownBy(() -> mateService.findAllByMeetingIdIfMate(kaki, meeting.getId()))
           .isInstanceOf(OdyNotFoundException.class);
}

 

그러나, 왜 실제 UT 상황에서는 예외 케이스가 발생했을까요? 그 이유는 테스트가 `방에 나갔다가 동일 약속에 참여한다`는 유저 시나리오를 포괄하지 않고 단순 기능만을 검증하고 있었기 때문입니다. 즉 실제 유저의 유즈케이스가 아닌 단일 기능에 대한 검증이다 보니 테스트가 유즈 케이스에 따른 예외 상황을 잡아내지 못하고 있었던 것입니다.

 

그럼 유저 경험 상에 발생가능한 예외를 해결하고 검증하기 위해 오디 팀이 던져야 할 질문은 명확했습니다.

"유즈케이스를 포괄하도록 테스트를 작성하는 방법은 무엇일까요?"


Action : 동적 인수 테스트 활용하기

결론적으로는 인수 테스트(User Acceptance Test)를 동적 테스트(Dynamic Test)를 활용하여 유즈 케이스를 포괄하는 테스트를 작성하였습니다.

 

그럼 먼저 유저 인수 테스트와 동적 테스트란 각각 무엇을 의미하는 것일까요?


유저 인수 테스트(User Acceptance Testing)이란

 

스탠포드 대학교 사이트 설명에 따르면 유저 인수 테스트(User Acceptance Testing)란 현실 문제에 맞닿은 비즈니스 요구사안을 테스팅하는 것으로 실제 유저 층의 비즈니스 사용 스토리를 가정하여 소프트웨어를 테스팅하는 것을 의미합니다.  

 

즉, 기능을 테스트하는 것이 아닌 유저 시나리오가 정상적으로 동작하는지를 E2E 형식으로 검증하는 것으로 내부 코드가 아닌 비즈니스가 주요 관심사입니다.


동적 테스트(Dynamic Test)란

기존에 JUnit의 @Test를 사용한 정적 테스트의 경우, 컴파일 시점에 코드가 지정됩니다. 그러나 동적 테스트의 경우 런타임 동안에 테스트가 생성되고 수행이 됩니다. 

 

테코블 다이나믹 테스트에 관한 아티클에 따르면 동적 테스트에 대해 다음과 같은 장점을 꼽습니다.

1) 유연성 : 런타임 시점에 테스트 케이스를 생성 가능

2) 가독성 : 여러 메서드가 연쇄적으로 호출되는 상황에서 각 메서드가 무엇을 위한 테스트인지 명시 가능

 

예를 들어, 로그인 > 로그아웃의 시나리오를 테스트하고 싶다면 다음과 같은 동적 테스트 구성이 가능합니다.

class LoginControllerTest extends IntegrationTestSupport {

    String accessToken;

    @DisplayName("토큰으로 로그인 인증한다.")
    @TestFactory
    Stream<DynamicTest> dynamicTestsFromCollection() {
        return Stream.of(
                dynamicTest("이메일, 패스워드로 로그인한다.", () -> {
                    accessToken = RestAssured
                            .given().log().all()
                            .body(new TokenRequest(ADMIN_EMAIL, ADMIN_PASSWORD))
                            .contentType(MediaType.APPLICATION_JSON_VALUE)
                            .accept(MediaType.APPLICATION_JSON_VALUE)
                            .when().post("/login")
                            .then().log().all().extract().cookie("token");
                }),
                dynamicTest("토큰으로 로그인 여부를 확인하여 이름을 받는다.", () -> {
                    MemberResponse member = RestAssured
                            .given().log().all()
                            .cookie("token", accessToken)
                            .accept(MediaType.APPLICATION_JSON_VALUE)
                            .when().get("/login/check")
                            .then().log().all()
                            .statusCode(HttpStatus.OK.value()).extract().as(MemberResponse.class);

                    assertThat(member.name()).isEqualTo(ADMIN_NAME);
                }),
                dynamicTest("로그아웃하면 토큰이 비어있다.", () -> {
                    String cookie = RestAssured
                            .given().log().all()
                            .cookie("token", accessToken)
                            .accept(MediaType.APPLICATION_JSON_VALUE)
                            .when().post("/logout")
                            .then().log().all()
                            .statusCode(HttpStatus.OK.value()).extract().cookie("token");

                    assertThat(cookie).isEmpty();
                })
        );
    }
}

 

다이나믹 테스트는 "이메일 패스워드로 로그인 한다", "로그아웃하면 토큰이 비어있다." 처럼 첫번째 인자로 테스트 제목을 붙일 수 있습니다. 따라서 어떤 테스트를 하고 있는지 명시할 수 있다는 장점이 있습니다.

 

동적 테스트를 구성할 때에는 다음 4가지를 주의하여야 합니다.

 

1) @TestFactory : 테스트 케이스를 생산하는 팩토리인 @TestFactory 애너테이션을 사용합니다.

 

2) @TestFactory 메서드는 private X , static X

 

3) 컬렉션 반환 : @TestFactory 메서드는 Stream, Collection, Iterable, Iterator를 반환해야 하며 그렇지 않으면 JunitException이 발생합니다.

 

4) 콜백 함수 지원 X : 개별 동적 테스트 간에  @BeforeEach, @AfterEach 같은 생명주기 콜백함수를 지원하지 않습니다.(전체 @TestFactory method를 대상으로는 동작 합니다.)

 

 

동적 테스트는 첫번째 인자에 테스트 이름, 두번째 인자에 실행 함수를 넣어 구성합니다.

@TestFactory
Stream<DynamicTest> dynamicTestsFromCollection() {
    return Stream.of(
            dynamicTest("testName1", () -> {
                //실행 함수1
            }),
            dynamicTest("testName2", () -> {
                //실행 함수2
            })
}

 


동적 인수 테스트로 유저 시나리오 테스트 하기

 

그럼 이제 동적 인수 테스트로 유저가 약속방에 나갔다가 동일 약속에 다시 들어오는 시나리오를 테스트해봅시다.

@DisplayName("동일 약속에 퇴장했다가 재참여가 가능하다")
@TestFactory
Stream<DynamicTest> canReattendMeeting() {
    LocalDateTime fiveMinutesLater = LocalDateTime.now().plusMinutes(5L);
    Meeting meeting = fixtureGenerator.generateMeeting(fiveMinutesLater);
    Member member = fixtureGenerator.generateMember();

    return Stream.of(
            dynamicTest("약속에 최초 참여한다", () -> {
                MateSaveRequestV2 mateSaveRequestV2 = dtoGenerator.generateMateSaveRequest(meeting);
                RestAssured.given().log().all()
                        .contentType(ContentType.JSON)
                        .header(HttpHeaders.AUTHORIZATION,
                                fixtureGenerator.generateAccessTokenValueByMember(member))
                        .body(mateSaveRequestV2)
                        .when()
                        .post("/v2/mates")
                        .then()
                        .statusCode(201);
            }),
            dynamicTest("약속에서 퇴장한다", () -> {
                RestAssured.given().log().all()
                        .contentType(ContentType.JSON)
                        .header(HttpHeaders.AUTHORIZATION,
                                fixtureGenerator.generateAccessTokenValueByMember(member))
                        .when()
                        .delete("/meetings/" + meeting.getId() + "/mate")
                        .then()
                        .statusCode(204);
            }),
            dynamicTest("약속에 재참여한다", () -> {
                MateSaveRequestV2 mateSaveRequestV2 = dtoGenerator.generateMateSaveRequest(meeting);
                RestAssured.given().log().all()
                        .contentType(ContentType.JSON)
                        .header(HttpHeaders.AUTHORIZATION,
                                fixtureGenerator.generateAccessTokenValueByMember(member))
                        .body(mateSaveRequestV2)
                        .when()
                        .post("/v2/mates")
                        .then()
                        .statusCode(201);
            })
    );
}

 

이제 단순 기능을 넘어 유저 시나리오를 포괄하는 테스트를 작성할 수 있게 되었습니다. 

 


Result : 유지보수에 좋은 유저 시나리오 테스트 코드

 

 

결론적으로 UT를 통해 커버하지 못했던 예외 케이스에 대해 동적 인수 테스트를 작성하고, 복잡한 기능을 유지보수하기 쉬운 구조의 테스트 코드로 작성할 수 있게 되었습니다.

 

또한, 동적 인수 테스트는 비즈니스 관점을 넘어 내부 코드 관점에서도 시나리오별 동작이 되지 않는 원인을 더욱 쉽게 특정할 수 있게 도와줍니다.

 

여러 전제가 필요한 유저 시나리오나 연쇄된 메서드 호출이 일어나는 유저 시나리오 테스트의 경우 given-when-then 절 중 필연적으로 테스트 세팅을 위해 given이 두꺼워지는 과정을 겪게됩니다. 이는 테스트 실패 시 실패의 원인을 특정할 수 없는 문제로 이어지고 사용자 시나리오가 예상대로 동작하지 않는 문제의 원인을 특정하기 힘들게 만듭니다.

 

그러나 동적 인수 테스트의 경우 어떤 유저 시나리오인지가 코드 상에서 읽힌다는 장점이 있습니다. 더불어 만약 테스트가 실패하더라도 개별 동적 테스트의 성공/실패 여부를 판단하기 때문에 시나리오 상 동작하지 않는 기능의 원인을 쉽게 특정할 수 있게 됩니다. 

 

동적 인수 테스트와 관한 글은 여기까지 입니다.

앞으로 유저 시나리오를 더욱 세분화하고 BDD 스타일에 매몰되어 코드가 뭉쳐져 있는 테스트 코드들에 대해 E2E 관점에서 주요 유저 스토리를 구성하고 동적 인수테스트로 리팩터링해볼 예정입니다.

 

오개념이나 오타와 관한 말씀은 언제나 환영입니다.

 

그럼 오늘도 행복하세요:)

 

reference)

https://tecoble.techcourse.co.kr/post/2020-07-31-dynamic-test/