DIP 스위치 계산기 앱에 넣은 오목·체스·장기 규칙을 테스트하던 중 오목 대각선 테스트에서 NPE가 났다. 원인은 규칙 엔진이 아니라 같은 칸을 두 번 쓰는 테스트 데이터였다. 첫 테스트부터 CI 수정, 연습 AI 추가까지 약 22분을 정리했다.

규칙 엔진에 기본 테스트 여섯 개를 추가했다
계산기 앱의 게임센터에는 오목(15×15), 장기(9×10), 체스(8×8)가 있었다. 세 게임의 규칙은 BoardRules가 판정했다.
판은 문자열 리스트로 저장하고 칸 번호는 행 × 너비 + 열이다. 체스에서 0번은 a8, 52번은 e2다. play()는 잘못된 수에 null을 반환한다.
10월 19일 12시 08분 55초에 첫 테스트 6개를 추가했다. 기본 규칙과 차례 제한을 확인하는 내용이었다.
- 오목: 가로 다섯 개면 흑 승리, 이미 돌이 있는 칸은 거부
- 체스: 첫 수 합법수 20개, 흑 선공 거부, 막힌 킹 이동 거부
- 장기: 병은 앞과 옆으로만, 뒤로는 못 감
CI는 sdkmanager를 못 찾아 두 번 실패했다
16초 뒤 단위 테스트와 디버그 APK 빌드용 GitHub Actions를 추가했다. 첫 실행은 Android SDK 설정 단계에서 21초 만에 실패했다.
Warning: Failed to find package 'tools'
Error: The process '/usr/local/lib/android/sdk/cmdline-tools/16.0/bin/sdkmanager' failed with exit code 1
56초 뒤 설정 액션을 빼고 러너의 SDK를 사용했다. 하지만 sdkmanager: command not found와 종료 코드 127로 또 실패했다.
47초 뒤 SDK 경로를 지정하고 cmdline-tools에서 sdkmanager를 찾아 실행하도록 바꿨다.
env:
ANDROID_HOME: /usr/local/lib/android/sdk
ANDROID_SDK_ROOT: /usr/local/lib/android/sdk
steps:
- name: Prepare Android SDK
run: |
SDKMANAGER=$(find "$ANDROID_HOME/cmdline-tools" -name sdkmanager -type f | head -1)
echo "Using $SDKMANAGER"
yes | "$SDKMANAGER" --licenses > /dev/null || true
"$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"
이 실행은 12시 15분 28초에 성공했다. 워크플로를 추가하고 6분 17초, 세 번째 시도였다.

체크메이트·앙파상·승격까지 테스트를 늘렸다
12시 18분 54초에는 장기 패스와 체스 캐슬링 검사를 더했다. 장기 패스 뒤 차례만 바뀌고 판은 그대로인지 확인했다.
chessCanCastleAfterClearingHomeRow는 이름과 실제 검사가 다르다. 초기 배치에서 62 !in legal(60)만 확인하며 캐슬링 성공은 검사하지 않는다.
12시 24분 11초에는 체크메이트와 특수 이동 등 테스트 4개를 추가했다.
- 바보 메이트: f3, e5, g4, Qh4#로 흑 승리
- 앙파상: exd6 후 d5의 흑 폰이 사라짐
- 승격: a7 폰을 나이트로 승격
- 오목: ↘ 대각선 다섯 개로 흑 승리
대각선 오목 테스트가 NPE로 깨졌다
CI는 약 6분 뒤 실패했다. 전체 13개 중 1개 실패였지만 BoardRulesTest는 12개뿐이었다. 다른 1개의 테스트 클래스는 확인하지 못했다.
BoardRulesTest > omokDiagonalFiveWins FAILED
java.lang.NullPointerException at BoardRulesTest.kt:101
13 tests completed, 1 failed
> Task :app:testDebugUnitTest FAILED
BUILD FAILED in 5m 42s
101번째 줄은 백이 두는 줄이었다. 흑은 0, 16, 32번으로 대각선을 내려가고, 백은 14, 15, 16번에 둔다. 세 번째 백 돌이 흑이 이미 둔 16번 칸에 겹친다.

로그에는 NPE와 줄 번호만 있었고, 원인은 코드의 착수 순서에서 추론했다. 점유한 칸에 두면 play()가 null을 내고, 테스트의 !!가 NPE를 일으킨다.
p = BoardRules.play(p, BoardMove(-1, n * 16))!!
- if (n < 4) p = BoardRules.play(p, BoardMove(-1, 14 + n))!!
+ if (n < 4) p = BoardRules.play(p, BoardMove(-1, 200 + n))!!
백 돌을 200~203번으로 옮긴 뒤 CI는 성공했다. 수정 커밋은 앞 커밋 16초 뒤인 12시 24분 27초로, 실패 로그보다 먼저였다. 어떻게 버그를 발견했는지는 기록에 없다.
가벼운 연습 AI를 추가했지만 화면 연결은 아직이다
12시 30분 52초에는 오프라인 연습 AI BoardAi를 추가했다. 대회 수준이 아닌 가벼운 연습용으로, CPU는 1번 진영을 맡는다.
- 오목: 바로 이기는 칸, 상대가 이기는 칸 막기, 빈 판이면 가운데 순으로 본다
- 오목 나머지: 기존 돌 2칸 이내 칸 중 이어진 돌과 무작위 점수로 고른다
- 체스·장기: 합법수마다 잡는 기물 가치 ×10, 가운데, 전진 점수와 무작위 점수를 더한다
return moves.maxByOrNull { move ->
val target = position.at(move.to)
val captureScore = pieceValue(target) * 10
val middleScore = 4 - abs(center - destinationColumn)
val forwardScore = if (position.game == BoardGame.CHESS) destinationRow else -destinationRow
captureScore + middleScore + forwardScore + Random.nextInt(5)
}
한 수 앞만 보는 탐욕 방식이다. 자기 킹을 위험에 두는 수는 legal()이 미리 거른다. 다만 상대의 다음 수는 읽지 않는다.

첫 테스트부터 AI 추가까지 21분 57초였다. BoardAi 전용 테스트가 없고, 확인한 게임 파일 5개에도 호출부가 없어 화면 연결 전으로 판단했다.
이번에 배운 것
- 실패한 수를 null로 돌려주는 API는 테스트의
!!에서 NPE로 드러난다. - 테스트가 깨지면 규칙보다 테스트 데이터부터 의심할 만하다.
- 테스트 이름은 실제 검사 내용과 맞춰야 나중에 덜 헷갈린다.
다음에는 캐슬링 검사를 이름에 맞게 보강할 생각이다. 연습 AI의 테스트와 화면 연결도 남았다.
자주 묻는 질문
Q. 테스트에서 !! 대신 무엇을 쓰면 원인이 잘 보이나?
A. assertNotNull로 먼저 확인하면 실패 메시지가 더 분명해진다. 이번 테스트 코드는 !!를 그대로 썼다.
Q. GitHub Actions에서 sdkmanager를 못 찾을 때는?
A. 이번에는 ANDROID_HOME을 지정하고 cmdline-tools 아래에서 sdkmanager를 찾아 실행해 해결했다.
관련 글: 블루투스 대국 Android 12 권한 방어와 메시지 순서 수정까지 10분
참고: Android Developers: 로컬 단위 테스트 빌드
'프로젝트 > DIP 스위치 계산기' 카테고리의 다른 글
| Play 결제 광고 제거를 다 만들고, 메뉴에서는 입구만 숨겼다 (0) | 2026.10.22 |
|---|---|
| R8 릴리스 시작 크래시에 WorkManager keep 규칙 두 개를 더했다 (0) | 2026.10.21 |
| 블루투스 대국 Android 12 권한 방어와 메시지 순서 수정까지 10분 (0) | 2026.10.20 |
| JDK 25 경고에 Gradle을 21로 고정했다가 3분 만에 AGP 9로 올렸다 (0) | 2026.10.20 |