$30 off During Our Annual Pro Sale. View Details »

NDC22 <달빛조각사>에서 서버 테스트 코드를 작성하는 방법

NDC22 <달빛조각사>에서 서버 테스트 코드를 작성하는 방법

Jongbin Oh

June 09, 2022
Tweet

More Decks by Jongbin Oh

Other Decks in Programming

Transcript

  1. Subtitles
    <달빛조각사>에서 서버 테스트 코드를 작성하는 방법
    XLGAMES 스튜디오 Q5
    오종빈

    View Slide

  2. Subtitles
    2
    <달빛조각사>에서 서버 테스트 코드를 작성하는 방법
    모바일 MMORPG
    남희성 작가의 달빛조각사 소설이 원작
    2019년 10월 10일 런칭
    국내, 대만, 글로벌 서비스 중
    서버 elixir, 클라이언트 unity

    View Slide

  3. Subtitles
    3
    <달빛조각사>에서 서버 테스트 코드를 작성하는 방법
    어떻게 서버 테스트 뿌리내렸는지
    작성하는지
    기반 코드를 만들었는지
    더 빠르게 했는지

    View Slide

  4. Subtitles
    4
    2013~현재: XLGAMES
    문명온라인, 달빛조각사
    2010~2013: NC
    리니지 3
    (2nd)
    , 리니지 이터널
    2005~2010: 넥슨 코리아
    마비노기, 허스키 익스프레스
    NDC07 AlienBrain을 이용한 빌드, 패치 시스템
    NDC12 게임 물리 엔진의 내부 동작 원리 이해
    오 종 빈
    게임 프로그래머
    ohyecloudy.com
    [email protected]

    View Slide

  5. Subtitles
    5
    서버 테스트 뿌리내리기
    초반 작업 세 가지 | 테스트 성공 상태 유지 | 열심히 테스트 추가하기 | 테스트 기반 다지기
    서버 테스트 작성하기
    유닛 테스트와 통합 테스트는 몇 대 몇? | 통합 테스트 | 유닛 테스트 | 정리
    더 짜기 쉽고 더 튼튼하게
    셋업을 손쉽게 | 게임 테이블 데이터 | 패킷 순서 | sleep이 필요할 땐
    병렬 테스트로 더 빠르게
    순차 실행은 답이 없다 | 게임 테이블 데이터 수정이 문제
    브로드캐스팅 패킷 정밀 검사 | 일부는 순차 테스트로 유지 | 결과
    마무리


    View Slide

  6. Subtitles
    6
    서버 테스트 뿌리내리기
    열심히 테스트 추가하기
    초반 작업 세 가지
    테스트 성공 상태 유지

    View Slide

  7. Subtitles
    7
    초반 작업 세 가지
    구성원들의 동의
    테스트 환경 구축
    주요 로직 테스트
    달빛조각사팀에 합류하기 전
    완료되었던
    송재경 대표님이 해결

    View Slide

  8. Subtitles
    8
    서버 테스트 뿌리내리기
    테스트 기반 다지기
    테스트 성공 상태 유지
    열심히 테스트 추가하기
    초반 작업 세 가지

    View Slide

  9. Subtitles
    9
    실패를 방치할수록
    실패 원인 찾기 비용
    성공하도록 고치는 비용
    개발 프로세스
    실패를 관리하는 게 목표

    View Slide

  10. Subtitles
    10
    master 브랜치
    작업 브랜치
    코드 포맷팅 검사
    정적 코드 분석
    자동 실행
    코드 리뷰 후 머지
    merge
    서버 테스트 자동 실행

    View Slide

  11. Subtitles
    11
    테스트 실행
    master 브랜치에 머지 후
    자동으로 실행
    슬랙
    slack
    연동
    깃랩
    gitlab
    메일이 불친절해서 봇 제작
    서버 테스트 성공 실패 여부를 쉽게 확인

    View Slide

  12. Subtitles
    12
    서버 테스트 뿌리내리기
    열심히 테스트 추가하기
    테스트 기반 다지기
    테스트 성공 상태 유지
    초반 작업 세 가지

    View Slide

  13. Subtitles
    13
    느슨한 규칙
    테스트가 없어도 코드 리뷰 진행
    하지만 테스트를 깨면 반드시 고쳐야 한다

    View Slide

  14. Subtitles
    14
    테스트 짜는 프로그래머가 늘어남
    테스트 효용성을 느낀 후
    코드 리뷰를 통해 테스트를 짜는 방법이 전파
    테스트 커버리지
    월드 서버 측정 시 64.5%

    View Slide

  15. Subtitles
    15
    서버 테스트 뿌리내리기
    테스트 기반 다지기
    열심히 테스트 추가하기
    테스트 성공 상태 유지

    View Slide

  16. Subtitles
    16
    테스트를 짜기 쉽게 한다
    캐릭터 생성, 월드 입장 등을 간단하게
    테스트 코드 최적화
    빠르면 빠를수록 좋다
    가끔 깨지는
    flaky
    테스트 원인 분석
    구조적 개선 진행
    뒤에서 더 자세히

    View Slide

  17. Subtitles
    17
    서버 테스트 작성하기
    유닛 테스트
    통합 테스트
    유닛 테스트와 통합 테스트는 몇 대 몇?

    View Slide

  18. Subtitles
    18
    유닛 테스트
    통합 테스트
    E2E 테스트
    (End to End)
    테스트 개수
    테스트 스토리 작성 손쉽다
    유저 행동과 유사
    테스트 대상의 고립도
    테스트 결과가 안정적
    빠르다

    View Slide

  19. Subtitles
    19
    통합 테스트
    유닛 테스트
    테스트 개수
    달빛 조각사에서는
    자동화된 E2E 테스트는 없다
    통합 테스트 비중이 높다
    전체 테스트의 90%

    View Slide

  20. Subtitles
    20
    서버 테스트 작성하기
    정리
    유닛 테스트와 통합 테스트는 몇 대 몇?
    유닛 테스트
    통합 테스트

    View Slide

  21. Subtitles
    21
    테스트 코드의 효용성
    서버 컨텐츠 자체를 테스트
    버그 재연에도 유용
    디자인 변경에 유연
    내부 구현 변경이 테스트에 영향을 주는 경우가 적음

    View Slide

  22. Subtitles
    22
    테스트 DB
    월드 서버
    테스트 코드
    패킷
    Query
    테스트 결과 확인 용도

    View Slide

  23. Subtitles
    23
    스토리를 작성하라
    클라이언트처럼 패킷을 주고 받는 스토리
    내부 상태 확인 X
    동작을 확인

    View Slide

  24. Subtitles
    24
    캐릭터마다 독립된 보관함을 가져야 한다
    1. 캐릭터 보관함 설치
    2. 아이템을 생성해 보관함에 넣기
    3. 같은 계정 다른 캐릭터로 접속
    4. 보관함이 비어있는 걸 확인
    5. 아이템을 생성해 보관함에 넣기
    6. …

    View Slide

  25. Subtitles
    25
    test "캐릭터마다 독립된 보관함을 가져야 한다", %{state1: state} do
    desc("캐릭터 보관함 설치")
    bank_type = TableHelper.new_bank(:character)
    {state, _} = BankHelper.install_bank(state, bank_type)
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type,
    [%{type: item_type, count: 2}])
    test "캐릭터마다 독립된 보관함을 가져야 한다", %{state1: state} do
    desc("캐릭터 보관함 설치")
    bank_type = TableHelper.new_bank(:character)
    {state, _} = BankHelper.install_bank(state, bank_type)
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type,
    [%{type: item_type, count: 2}])
    desc("재접속했을 때, 캐릭터 보관함에 아이템이 그대로여야 한다")
    {state, _} = ConnectionHelper.reconnect_enter(state)
    BankHelper.assert_bank_items_after_load(state, bank_type, [
    %{type: item_type, count: 2, locked: false}
    ])
    desc("같은 계정 다른 캐릭터로 접속")
    name = ConnectionHelper.test_common_char_name(state, 1)
    state = put_in(state.common_char_name, name)
    {state, _char_id2, _} = common_character_setup(state: state)
    desc("다른 캐릭터로 접속했을 때, 캐릭터 보관함이 설치되어 있어야 한다")
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    desc("다른 캐릭터가 캐릭터 보관함에 저장한 아이템이 보이지 않아야 한다")
    assert BankHelper.assert_empty_bank(state, bank_type)
    desc("재접속해도 비어있는 캐릭터 보관함이 그대로여야 한다 ")
    {state, _} = ConnectionHelper.reconnect_enter(state))
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    assert BankHelper.assert_empty_bank(state, bank_type)
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type, [%{type: item_type, count: 2}])

    View Slide

  26. Subtitles
    26
    desc("재접속했을 때, 캐릭터 보관함에 아이템이 그대로여야 한다")
    {state, _} = ConnectionHelper.reconnect_enter(state)
    BankHelper.assert_bank_items_after_load(state, bank_type, [
    %{type: item_type, count: 2, locked: false}
    ])
    desc("같은 계정 다른 캐릭터로 접속")
    name = ConnectionHelper.test_common_char_name(state, 1)
    state = put_in(state.common_char_name, name)
    {state, _char_id2, _} = common_character_setup(state: state)
    desc("다른 캐릭터로 접속했을 때, 캐릭터 보관함이 설치되어 있어야 한다")
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    test "캐릭터마다 독립된 보관함을 가져야 한다", %{state1: state} do
    desc("캐릭터 보관함 설치")
    bank_type = TableHelper.new_bank(:character)
    {state, _} = BankHelper.install_bank(state, bank_type)
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type,
    [%{type: item_type, count: 2}])
    desc("재접속했을 때, 캐릭터 보관함에 아이템이 그대로여야 한다")
    {state, _} = ConnectionHelper.reconnect_enter(state)
    BankHelper.assert_bank_items_after_load(state, bank_type, [
    %{type: item_type, count: 2, locked: false}
    ])
    desc("같은 계정 다른 캐릭터로 접속")
    name = ConnectionHelper.test_common_char_name(state, 1)
    state = put_in(state.common_char_name, name)
    {state, _char_id2, _} = common_character_setup(state: state)
    desc("다른 캐릭터로 접속했을 때, 캐릭터 보관함이 설치되어 있어야 한다")
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    desc("다른 캐릭터가 캐릭터 보관함에 저장한 아이템이 보이지 않아야 한다")
    assert BankHelper.assert_empty_bank(state, bank_type)
    desc("재접속해도 비어있는 캐릭터 보관함이 그대로여야 한다 ")
    {state, _} = ConnectionHelper.reconnect_enter(state))
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    assert BankHelper.assert_empty_bank(state, bank_type)
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type, [%{type: item_type, count: 2}])

    View Slide

  27. Subtitles
    27
    desc("다른 캐릭터가 캐릭터 보관함에 저장한 아이템이 보이지 않아야 한다")
    assert BankHelper.assert_empty_bank(state, bank_type)
    desc("재접속해도 비어있는 캐릭터 보관함이 그대로여야 한다 ")
    {state, _} = ConnectionHelper.reconnect_enter(state))
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    assert BankHelper.assert_empty_bank(state, bank_type)
    test "캐릭터마다 독립된 보관함을 가져야 한다", %{state1: state} do
    desc("캐릭터 보관함 설치")
    bank_type = TableHelper.new_bank(:character)
    {state, _} = BankHelper.install_bank(state, bank_type)
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type,
    [%{type: item_type, count: 2}])
    desc("재접속했을 때, 캐릭터 보관함에 아이템이 그대로여야 한다")
    {state, _} = ConnectionHelper.reconnect_enter(state)
    BankHelper.assert_bank_items_after_load(state, bank_type, [
    %{type: item_type, count: 2, locked: false}
    ])
    desc("같은 계정 다른 캐릭터로 접속")
    name = ConnectionHelper.test_common_char_name(state, 1)
    state = put_in(state.common_char_name, name)
    {state, _char_id2, _} = common_character_setup(state: state)
    desc("다른 캐릭터로 접속했을 때, 캐릭터 보관함이 설치되어 있어야 한다")
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    desc("다른 캐릭터가 캐릭터 보관함에 저장한 아이템이 보이지 않아야 한다")
    assert BankHelper.assert_empty_bank(state, bank_type)
    desc("재접속해도 비어있는 캐릭터 보관함이 그대로여야 한다 ")
    {state, _} = ConnectionHelper.reconnect_enter(state))
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    assert BankHelper.assert_empty_bank(state, bank_type)
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type, [%{type: item_type, count: 2}])

    View Slide

  28. Subtitles
    28
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type, [%{type: item_type, count: 2}])
    test "캐릭터마다 독립된 보관함을 가져야 한다", %{state1: state} do
    desc("캐릭터 보관함 설치")
    bank_type = TableHelper.new_bank(:character)
    {state, _} = BankHelper.install_bank(state, bank_type)
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type,
    [%{type: item_type, count: 2}])
    desc("재접속했을 때, 캐릭터 보관함에 아이템이 그대로여야 한다")
    {state, _} = ConnectionHelper.reconnect_enter(state)
    BankHelper.assert_bank_items_after_load(state, bank_type, [
    %{type: item_type, count: 2, locked: false}
    ])
    desc("같은 계정 다른 캐릭터로 접속")
    name = ConnectionHelper.test_common_char_name(state, 1)
    state = put_in(state.common_char_name, name)
    {state, _char_id2, _} = common_character_setup(state: state)
    desc("다른 캐릭터로 접속했을 때, 캐릭터 보관함이 설치되어 있어야 한다")
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    desc("다른 캐릭터가 캐릭터 보관함에 저장한 아이템이 보이지 않아야 한다")
    assert BankHelper.assert_empty_bank(state, bank_type)
    desc("재접속해도 비어있는 캐릭터 보관함이 그대로여야 한다 ")
    {state, _} = ConnectionHelper.reconnect_enter(state))
    assert BankHelper.get_installed_bank_types(state) == [bank_type]
    assert BankHelper.assert_empty_bank(state, bank_type)
    desc("캐릭터 보관함에 넣을 수 있는 아이템을 만든다")
    %{item_db_id: item_db_id, item_type: item_type} =
    BankHelper.create_some_storable_item_get_item(state, bank_type, 2)
    desc("아이템을 보관하고 확인한다")
    BankHelper.store_items(state, bank_type, %{id: item_db_id, count: 2})
    InventoryHelper.assert_remain_items(state, %{}, %{item_db_id => 2})
    BankHelper.assert_bank_items(state, bank_type, [%{type: item_type, count: 2}])

    View Slide

  29. Subtitles
    29
    존 이동 버그 재현 테스트 예제
    버그 재현에도 유용
    클라이언트처럼 패킷을 주고 받는다
    간혹 존 이동이 실패해 이전 존으로 이동한다는 제보
    발생 빈도가 낮다
    재현 조건은 모르는 상태

    View Slide

  30. Subtitles
    30
    @tag setup_char_count: 2
    test "reserve channel except channels being destroyed",
    %{state1: state1, state2: state2} do
    desc("채널에 사람이 없으면 바로 파괴하게 해서 파괴와 동시에 캐릭터 입장을 시뮬레이션")
    TestTable.scoped_modify_const(:PRESERVE_TIME, 0)
    TestTable.scoped_modify_const(:MOVER_PRESERVE_TIME, 0)
    @tag setup_char_count: 2
    test "reserve channel except channels being destroyed",
    %{state1: state1, state2: state2} do
    desc("채널에 사람이 없으면 바로 파괴하게 해서 파괴와 동시에 캐릭터 입장을 시뮬레이션")
    TestTable.scoped_modify_const(:PRESERVE_TIME, 0)
    TestTable.scoped_modify_const(:MOVER_PRESERVE_TIME, 0)
    desc("두 캐릭터가 서로 다른 두 지역을 동시에 왔다갔다 한다")
    desc("채널 파괴 타이밍과 입장 타이밍을 근접하게 한다")
    {Q.const_zone_south_serabourg(), Q.const_zone_serabourg()}
    # 첫번째 요소와 두번째 요소 위치를 바꾸는 걸 반복한다
    |> Stream.iterate(fn {z1, z2} -> {z2, z1} end)
    # 100번 정도 시도해본다
    |> Enum.take(100)
    |> Enum.reduce({state1, state2}, fn {z1, z2}, {state1, state2} ->
    # 동시에 포탈로 존을 이동한다
    [
    Task.async(fn -> TeleportHelper.portal(state1, z1) |> elem(0) end),
    Task.async(fn -> TeleportHelper.portal(state2, z2) |> elem(0) end)
    ]
    |> Enum.map(&Task.await/1)
    |> List.to_tuple()
    end)
    end

    View Slide

  31. Subtitles
    31
    desc("두 캐릭터가 서로 다른 두 지역을 동시에 왔다갔다 한다")
    desc("채널 파괴 타이밍과 입장 타이밍을 근접하게 한다")
    {Q.const_zone_south_serabourg(), Q.const_zone_serabourg()}
    # 첫번째 요소와 두번째 요소 위치를 바꾸는 걸 반복한다
    |> Stream.iterate(fn {z1, z2} -> {z2, z1} end)
    # 100번 정도 시도해본다
    |> Enum.take(100)
    @tag setup_char_count: 2
    test "reserve channel except channels being destroyed",
    %{state1: state1, state2: state2} do
    desc("채널에 사람이 없으면 바로 파괴하게 해서 파괴와 동시에 캐릭터 입장을 시뮬레이션")
    TestTable.scoped_modify_const(:PRESERVE_TIME, 0)
    TestTable.scoped_modify_const(:MOVER_PRESERVE_TIME, 0)
    desc("두 캐릭터가 서로 다른 두 지역을 동시에 왔다갔다 한다")
    desc("채널 파괴 타이밍과 입장 타이밍을 근접하게 한다")
    {Q.const_zone_south_serabourg(), Q.const_zone_serabourg()}
    # 첫번째 요소와 두번째 요소 위치를 바꾸는 걸 반복한다
    |> Stream.iterate(fn {z1, z2} -> {z2, z1} end)
    # 100번 정도 시도해본다
    |> Enum.take(100)
    |> Enum.reduce({state1, state2}, fn {z1, z2}, {state1, state2} ->
    # 동시에 포탈로 존을 이동한다
    [
    Task.async(fn -> TeleportHelper.portal(state1, z1) |> elem(0) end),
    Task.async(fn -> TeleportHelper.portal(state2, z2) |> elem(0) end)
    ]
    |> Enum.map(&Task.await/1)
    |> List.to_tuple()
    end)
    end

    View Slide

  32. Subtitles
    32
    |> Enum.reduce({state1, state2}, fn {z1, z2}, {state1, state2} ->
    # 동시에 포탈로 존을 이동한다
    [
    Task.async(fn -> TeleportHelper.portal(state1, z1) |> elem(0) end),
    Task.async(fn -> TeleportHelper.portal(state2, z2) |> elem(0) end)
    ]
    |> Enum.map(&Task.await/1)
    |> List.to_tuple()
    end)
    end
    @tag setup_char_count: 2
    test "reserve channel except channels being destroyed",
    %{state1: state1, state2: state2} do
    desc("채널에 사람이 없으면 바로 파괴하게 해서 파괴와 동시에 캐릭터 입장을 시뮬레이션")
    TestTable.scoped_modify_const(:PRESERVE_TIME, 0)
    TestTable.scoped_modify_const(:MOVER_PRESERVE_TIME, 0)
    desc("두 캐릭터가 서로 다른 두 지역을 동시에 왔다갔다 한다")
    desc("채널 파괴 타이밍과 입장 타이밍을 근접하게 한다")
    {Q.const_zone_south_serabourg(), Q.const_zone_serabourg()}
    # 첫번째 요소와 두번째 요소 위치를 바꾸는 걸 반복한다
    |> Stream.iterate(fn {z1, z2} -> {z2, z1} end)
    # 100번 정도 시도해본다
    |> Enum.take(100)
    |> Enum.reduce({state1, state2}, fn {z1, z2}, {state1, state2} ->
    # 동시에 포탈로 존을 이동한다
    [
    Task.async(fn -> TeleportHelper.portal(state1, z1) |> elem(0) end),
    Task.async(fn -> TeleportHelper.portal(state2, z2) |> elem(0) end)
    ]
    |> Enum.map(&Task.await/1)
    |> List.to_tuple()
    end)
    end

    View Slide

  33. Subtitles
    33
    서버 테스트 작성하기
    유닛 테스트와 통합 테스트는 몇 대 몇?
    통합 테스트
    정리
    유닛 테스트

    View Slide

  34. Subtitles
    34
    다양한 케이스에 대한 테스트가 필요할 때
    통합 테스트의 복잡한 준비 과정 X
    테스트 대상
    알고리즘
    기반 유틸 함수

    View Slide

  35. Subtitles
    35
    요리 재료로 적절한 레시피 찾기 테스트 예제
    다양한 재료와 레시피로 테스트해야 한다
    통합 테스트로 하려면 다 생성해야 함
    레시피를 찾는 함수 격리 테스트가 가능
    유닛 테스트로 인자만 바꿔서 테스트할 수 있다

    View Slide

  36. Subtitles
    36
    test "요리 재료 종류가 일치해야 한다. 모자라거나 초과하는 레시피를 선택하면 안 된다" do
    pick =
    build_pick_optimal_recipe_ids_partial_func(
    [
    %{:craft_id => 1, :materials => %{1 => 5}},
    %{:craft_id => 2, :materials => %{1 => 5, 2 => 2}},
    %{:craft_id => 3, :materials => %{1 => 5, {:meat, -1} => 1, 3 => 1}}
    ]
    )
    assert pick.(%{1 => 5, 2 => 5}) == [2],
    "모자라거나 넘치는 1, 3 레시피를 선택하면 안 된다“
    assert pick.(%{1 => 5, 2 => 5, 3 => 4, 4 => 3}) == [3],
    "모자라는 1, 2 레시피를 선택하면 안 된다"
    end
    레시피를 찾을 때
    사용하는 필드만 정의
    아이템 타입에 해당하는
    값을 넘겨서 테스트

    View Slide

  37. Subtitles
    37
    간단한 함수 테스트
    몇 줄 안 되는데, 별도 테스트 파일에 정의해야 하나?
    함수 설명에 적으면 사용 예제로 참고할 수 있는데

    View Slide

  38. Subtitles
    38
    @doc """
    다른 타입 아이템 리스트를 받아 각 type별 count를 계산한다.
    {type, count} 튜플 리스트를 리턴한다.
    iex> ItemUtil.to_type_count_tuples([])
    []
    iex> ItemUtil.to_type_count_tuples([
    ...> %Item{type: 1, count: 1},
    ...> %Item{type: 2, count: 10},
    ...> %BankItem{type: 1, count: 2},
    ...> {2, {:none, 0, 0}, 3}])
    [{1, 3}, {2, 13}]
    """
    def to_type_count_tuples(items) do
    # 멋진 구현
    end
    함수 설명
    실제로 테스트가 된다

    View Slide

  39. Subtitles
    39
    공용 유틸 함수를 doctest
    동작이 검증된 함수 사용 예제
    호출 빈도가 높은 유틸 함수 이해에 도움

    View Slide

  40. Subtitles
    40
    서버 테스트 작성하기
    통합 테스트
    유닛 테스트
    정리

    View Slide

  41. Subtitles
    41
    통합 테스트 비중이 높다
    유닛 테스트도 하긴 한다
    통합 테스트: 서버 동작, 게임 컨텐츠
    유닛 테스트: 알고리즘, 공용 유틸 함수

    View Slide

  42. Subtitles
    42
    더 짜기 쉽고 더 튼튼하게
    패킷 순서
    셋업을 손쉽게
    게임 테이블 데이터

    View Slide

  43. Subtitles
    43
    @tag setup_char_count: 2
    @tag setup_char_class: Q.char_class_mage()
    @tag setup_char_zone: Q.const_zone_test_zoo()
    @tag setup_char_makeup: char_makeup_female()
    @tag make_party: true
    test "끝내주는 테스트", %{state1: state, state2: state2} do
    # 테스트 코드
    end
    세팅할 캐릭터 수,
    직업, 성별 등을 설정
    테스트 코드 작성에 집중할 수 있고
    테스트 코드 가독성도 좋아진다.

    View Slide

  44. Subtitles
    44
    더 짜기 쉽고 더 튼튼하게
    sleep이 필요할 땐
    셋업을 손쉽게
    게임 테이블 데이터
    패킷 순서

    View Slide

  45. Subtitles
    45
    게임 테이블 데이터를 그대로 사용
    매번 테스트할 조건에 맞는 데이터 찾기
    찾는 게 귀찮아서 특정 데이터가 반복적으로 사용됨
    기획자가 데이터를 변경해서 테스트가 실패하기도

    View Slide

  46. Subtitles
    46
    @item_type 123123 # 보관함에 보관할 수 있는 아이템
    test "보관함 기본 동작", %{state1: state} do
    {state, %{bank_item_db_id: bank_item_db_id}} = BankHelper.install_bank(state, bank_type)
    desc("아이템을 보관한다")
    %{item_db_id: item_db_id} = ItemHelper.put_item(state, @item_type, 2)
    BankHelper.store_items(state, [
    %{
    bank_type: bank_type,
    request_items: [%{id: item_db_id, count: 1}]
    }
    ])
    #...
    end

    View Slide

  47. Subtitles
    47
    게임 테이블 데이터를 수정해서 사용
    해당 테스트가 끝나면 원래 속성으로 복구
    한땀한땀 전체 속성 정의는 필요 X
    기획자가 테스트를 깨뜨리지 않는다
    데이터 조작이 쉬워지니 다양한 속성을 테스트

    View Slide

  48. Subtitles
    48
    @item_type 123123 # 보관함에 보관할 수 있는 아이템
    test "보관함 기본 동작", %{state1: state} do
    # 테스트에 필요한 속성을 정의해서 기획자가 용도를 바꾸더라도 테스트가 깨지지 않게 설정
    TestTable.scoped_modify_table(:item_table, @item_type, fn old ->
    Map.put(old, "보관함_보관_여부", true)
    end)
    desc("아이템을 보관한다")
    %{item_db_id: item_db_id} = ItemHelper.put_item(state, @item_type, 2)
    # 멋진 테스트
    end

    View Slide

  49. Subtitles
    49
    더 짜기 쉽고 더 튼튼하게
    셋업을 손쉽게
    게임 테이블 데이터
    패킷 순서
    sleep이 필요할 땐

    View Slide

  50. Subtitles
    50
    # 몬스터가 플레이어에게 dot 데미지를 주는 버프를 걸고 때린다
    # 공격 데미지가 들어온다
    assert_packet_received(state, Q.sc_attack())
    # dot 데미지가 들어온다
    assert_packet_received(state, Q.sc_dot_damage())
    성공할 때도 있고 실패할 때도 있다
    공격 패킷이 빨리 가면 성공
    늦게 가면 실패

    View Slide

  51. Subtitles
    51
    # 몬스터가 플레이어에게 dot 데미지를 주는 버프를 걸고 때린다
    assert_packets_received(state, 2, fn
    {Q.sc_attack(), _field1, _field2} ->
    # 공격 데미지가 들어온다
    :ok
    {Q.sc_dot_damage(), _field1, _field2, _field3} ->
    # dot 데미지가 들어온다
    :ok
    _ ->
    :ignore
    end)
    pattern matching 사용
    순서에 상관없이 패킷이
    다 들어오면 성공
    하지만 조합이 쉽지 않다

    View Slide

  52. Subtitles
    52
    조합할 수 있는 패킷 매처 구현
    순서 상관없이 확인할 패킷을 리스트로 구성할 수 있다

    View Slide

  53. Subtitles
    53
    테스트 헬퍼 함수 디자인에 도움
    패킷 매처를 인자로 받아 추가적인 패킷 처리 가능
    몬스터 kill 함수를 만들었는데
    업적 달성, 레벨 업 패킷을 추가 처리하려면?

    View Slide

  54. Subtitles
    54
    CombatHelper.kill(
    state,
    monster_id,
    [
    PacketMatcherFactory.sc_achievement(57, count: 1, nth: 0, done: false),
    PacketMatcherFactory.sc_achievement(2, count: 1, nth: 0, done: false)
    ]
    )
    CombatHelper.kill(
    state,
    monster_id,
    PacketMatcherFactory.sc_class_level_up(exp: @gain_exp)
    )
    몬스터를 죽였을 때, 업적 달성
    몬스터를 죽이고 경험치 획득
    몬스터가 죽었을 때 받는 패킷은
    함수 내부에서 처리

    View Slide

  55. Subtitles
    55
    더 짜기 쉽고 더 튼튼하게
    게임 테이블 데이터
    패킷 순서
    sleep이 필요할 땐

    View Slide

  56. Subtitles
    56
    sleep 시간 하드코딩의 문제점
    넉넉한 sleep이 테스트를 느리게 한다
    간당간당한 sleep이 테스트를 깨지기 쉽게 한다
    # 아이템을 사용 후 db 에서도 삭제됐는지 확인
    # 저장하는데 텀이 있어서 약간 기다렸다가 확인한다
    Process.sleep(1000)
    get_db_item_count(state, item_type) == 0

    View Slide

  57. Subtitles
    57
    간격을 두고 여러 번 검사
    200ms 간격으로 10번 검사
    모두 실패해야 실패
    더 빠르고 튼튼한 테스트가 가능
    # 아이템을 사용 후 db 에서도 삭제됐는지 확인
    eventually(
    fn ->
    get_db_item_count(state, item_type) == 0
    end,
    count: 10,
    interval: 200
    )

    View Slide

  58. Subtitles
    58
    병렬 테스트로 더 빠르게
    브로드캐스팅 패킷 정밀 검사
    순차 실행은 답이 없다
    게임 테이블 데이터 수정이 문제

    View Slide

  59. Subtitles
    59
    점점 오래 걸리는 테스트 시간
    총 1376개 테스트, 총 3955s(65분)
    테스트 개별 소요 시간을 줄이는 데는 한계가 있다
    동시에 여러 서버 테스트를 실행해야 한다
    이렇게 테스트가 많아질 거라
    생각을 못 했다

    View Slide

  60. Subtitles
    60
    월드 서버
    테스트 코드 A
    패킷
    테스트 코드 B 테스트 코드 A가 끝나면 시작
    테스트 순차 실행 - 기존 방식

    View Slide

  61. Subtitles
    61
    월드 서버
    테스트 코드 A
    패킷
    테스트 코드 Z
    테스트 병렬 실행

    코어수 x2

    View Slide

  62. Subtitles
    62
    병렬 테스트로 더 빠르게
    일부는 순차 테스트로 유지
    순차 실행은 답이 없다
    게임 테이블 데이터 수정이 문제
    브로드캐스팅 패킷 정밀 검사

    View Slide

  63. Subtitles
    63
    데이터를 수정하면
    실행중인 다른 테스트에
    영향을 준다

    View Slide

  64. Subtitles
    64
    수정하지 말고 추가
    type id를 새로 발급해서 다른 테스트에 영향 X
    데이터의 모든 속성을 정의하는 건 괴롭다
    수정 확률이 낮은 옛날 데이터를 복사
    테스트에 필요한 속성을 덮어쓴다

    View Slide

  65. Subtitles
    65
    TableHelper.new_item(:scroll,
    count: 1,
    attrs: [bahsra_time: [abs: @test_time]],
    effects: [:bahsra_time]
    )
    TableHelper.new_achievement(:get_item_kind_grade, {:potion, Q.item_grade_type_poor()})
    TableHelper.new_drop_pack(
    %{
    TableHelper.new_drop_pack_class_gender() => [
    TableHelper.new_drop_pack_item(item_type1, min_count: 2, max_count: 2),
    TableHelper.new_drop_pack_item(item_type1, min_count: 3, max_count: 3),
    TableHelper.new_drop_pack_item(item_type2, min_count: 4, max_count: 4)
    ]
    },
    method: :each)

    View Slide

  66. Subtitles
    66
    병렬 테스트로 더 빠르게
    결과
    순차 실행은 답이 없다
    게임 테이블 데이터 수정이 문제
    브로드캐스팅 패킷 정밀 검사
    일부는 순차 테스트로 유지

    View Slide

  67. Subtitles
    67
    현재 테스트 중인 캐릭터가 대상인지 검사
    같은 지역에서 실행 중인 다른 테스트 영향일 수 있다
    attack 패킷의 damage 필드를 바로 검사 X
    공격자, 피격자를 확인 후에 검사
    다른 테스트에서 공격해 방송한 패킷일 수 있다

    View Slide

  68. Subtitles
    68
    병렬 테스트로 더 빠르게
    게임 테이블 데이터 수정이 문제
    브로드캐스팅 패킷 정밀 검사
    일부는 순차 테스트로 유지
    결과

    View Slide

  69. Subtitles
    69
    병렬 X - 게임 상수를 변경하는 테스트
    주로 시간, 기간에 관련된 상수
    다른 테스트에 영향을 줄 수 있어서 순차 실행
    # 캐릭터 삭제 대기 기간을 1초로 변경함
    TestTable.scoped_modify_const(:DELAYED_DELETE_CHAR_DELAY, 1)
    TestTable.scoped_modify_const(:SERVER_INTEGRATION_DELETE_CHAR_DELAY, 1)
    # 캐릭터 삭제 테스트 ...

    View Slide

  70. Subtitles
    70
    병렬 X - 컨텐츠 스위치 사용 테스트
    실시간으로 특정 컨텐츠를 활성화/비활성화
    다른 테스트에 영향을 줄 수 있어서 순차 실행
    # item_type을 팔 수 없게 설정한다
    Q5ContentSwitch.scoped_set_item_restriction(item_type, Q.item_restriction_type_no_trade(), true)
    # 팔 수 없는 아이템을 파는 테스트...
    # 남의 집 방문 컨텐츠를 off한다
    ContentSwitchHelper.scoped_content_switch(Q.content_switch_element_visit_house(), false)
    # 남의 집에 방문할 수 있는지 테스트...

    View Slide

  71. Subtitles
    71
    병렬 테스트로 더 빠르게
    브로드캐스팅 패킷 정밀 검사
    일부는 순차 테스트로 유지
    결과

    View Slide

  72. Subtitles
    72
    효과가 있다
    3955s(65분) -> 1826s(30분)
    아직 진행중
    순차 테스트를 염두에 두고 짠 테스트가 많다

    View Slide

  73. Subtitles
    73
    마무리

    View Slide

  74. Subtitles
    74
    서버 테스트 뿌리내리기
    초반 작업 세 가지 | 테스트 성공 상태 유지 | 열심히 테스트 추가하기 | 테스트 기반 다지기
    서버 테스트 작성하기
    유닛 테스트와 통합 테스트는 몇 대 몇? | 통합 테스트 | 유닛 테스트 | 정리
    더 짜기 쉽고 더 튼튼하게
    셋업을 손쉽게 | 게임 테이블 데이터 | 패킷 순서 | sleep이 필요할 땐
    병렬 테스트로 더 빠르게
    순차 실행은 답이 없다 | 게임 테이블 데이터 수정이 문제
    브로드캐스팅 패킷 정밀 검사 | 일부는 순차 테스트로 유지 | 결과
    마무리


    View Slide

  75. Subtitles
    75
    남은 일
    서버 테스트를 master 브랜치에 머지
    merge
    하기 전에 실행
    10분대 테스트 시간과 안정성이 확보돼야 한다
    master 브랜치
    작업 브랜치
    코드 포맷팅 검사
    정적 코드 분석
    자동 실행
    코드 리뷰 후 머지
    merge

    View Slide

  76. Subtitles
    76
    테스트를 짜는 게 막막하다면
    스토리 작성에 유리한 통합 테스트부터
    시작부터 이렇게
    게임 테이블 데이터는 추가
    병렬로 테스트를 실행

    View Slide

  77. Subtitles
    77
    Designing Elixir Systems with OTP
    James Edward Gray II, Bruce A. Tate
    Testing Elixir
    Andrea Leopardi, Jeffrey Matthias
    Google Testing Blog
    https://testing.googleblog.com/
    The Elixir programming language
    https://elixir-lang.org/
    feature branch workflow - atlassian.com
    https://bit.ly/3KSgplk
    slab - slack bot
    http://ohyecloudy.com/pnotes/archives/side-project-slab/


    View Slide

  78. Subtitles
    78
    들어주셔서 감사합니다

    View Slide