CI/CD 파이프라인 · 테스트 분할 · 실습
조각 수를 늘렸더니 실패하던 시험이 통과했다
목표
시험 묶음을 결정적으로 나누고, 조각별 결과를 기계가 읽는 형식으로 남기고, 다시 합쳐 하나의 판정을 만듭니다. 조각 수가 판정을 바꾸지 못하게 지키는 검사와, 기록된 소요 시간으로 가장 무거운 조각을 줄이는 배분까지 직접 만듭니다.
왜 중요한가
시험이 길어지면 사람이 결과를 기다리지 않습니다. 나눠 돌리는 것은 시험을 지우지 않고 벽시계 시간을 줄이는 가장 직접적인 수단이지만, 나눔은 새로운 실패 모드를 함께 들여옵니다. 배분이 실행마다 달라지면 '조각 2에서 실패' 라는 기록이 다음 실행에서 다른 시험을 가리켜 재현이 불가능해집니다. 조각별 결과를 대충 합치면 조각 하나가 통째로 죽었는데도 전체가 초록으로 보입니다. 그리고 늘 같은 순서로 돌아 숨어 있던 순서 의존성이, 조각이 갈리는 순간 드러납니다. 그래서 분할을 도입한 파이프라인에는 불변식이 하나 필요합니다 — 조각 수를 바꿔도 총 케이스 수와 총 판정은 같아야 한다. 이 실습은 그 불변식이 깨지는 장면을 직접 만들어 보고, 원인을 찾아 고친 뒤, 다시 깨지지 않게 지키는 게이트까지 만듭니다.
단계
1. 시험 대상 라이브러리 /root/shard/tests/sampleapp.py(모듈 수준 설정 딕셔너리 SETTINGS 를 반드시 둘 것)와, 같은 디렉터리에 test_*.py 파일 넷 이상·케이스 스무 개 이상을 만드세요. 그중 하나는 /root/shard/tests/test_slowio.py 로, time.sleep 으로 0.1초 이상 걸리는 케이스를 다섯 개 둡니다(뒤에서 조각 사이 시간 차이를 만듭니다). 이 단계의 모든 시험은 통과해야 합니다. 그리고 /root/shard/list_tests.sh [시험디렉터리] 를 만들어 시험 이름을 모듈.클래스.메서드 꼴로 정렬해 한 줄에 하나씩 내게 하세요(기본값은 /root/shard/tests). 마지막으로 전체를 한 번에 돌린 출력을 /root/shard/baseline.txt 에 저장합니다(Ran N tests 와 OK 가 들어 있어야 합니다).
2. /root/shard/shard.sh <조각번호> <조각수> 를 만드세요. 표준 입력으로 받은 시험 이름 목록에서 그 조각에 속하는 이름만 그대로 출력합니다. 조각은 이름의 sha256 앞 8자리(16진수)를 조각 수로 나눈 나머지로 정합니다. 조각 번호는 0부터 셉니다. 세 가지가 지켜져야 합니다 — (1) 조각들은 서로 겹치지 않고 모두 합치면 입력 전체가 된다, (2) 같은 입력이면 두 번 돌려도 글자 하나까지 같다, (3) 목록에서 시험 하나를 빼도 나머지 이름의 조각은 바뀌지 않는다. 확인한 결과를 /root/shard/deterministic.txt 에 남기세요 — 두 번 돌린 출력을 diff 한 결과가 비어 있다는 줄 하나와, 3조각으로 나눈 개수를 shard0=<수> shard1=<수> shard2=<수> 꼴로 적은 줄 하나입니다.
3. /root/shard/runner.py <시험디렉터리> <시험목록파일> <출력XML> <조각이름> 을 만드세요. 목록의 시험을 한 프로세스에서 목록 순서대로 돌리고 JUnit XML 을 씁니다. 뿌리 원소는 testsuite(속성 name·tests·failures·errors·time)이고, 시험마다 testcase(속성 classname·name·time)를 두며 실패는 failure, 오류는 error 자식 원소로 남깁니다. 그리고 /root/shard/run_shard.sh <조각번호> <조각수> [리포트디렉터리] 를 만들어 목록을 만들고 나누고 돌려 <리포트디렉터리>/shard-<조각번호>.xml 을 남기게 하세요(리포트 디렉터리 기본값은 /root/shard/reports). bash /root/shard/run_shard.sh 0 3 1 3 2 3 으로 세 조각의 리포트를 만들어 두세요.
4. /root/shard/merge.sh <리포트디렉터리> <기대조각수> 를 만드세요. 그 디렉터리의 *.xml 을 모두 읽어 한 줄만 출력합니다: PASS cases=<수> failures=<수> errors=<수> shards=<수> 또는 같은 모양의 FAIL .... 네 가지 가운데 하나라도 어긋나면 FAIL 이고 종료 코드는 1 입니다 — 리포트 파일 수가 기대 조각 수와 다르다, 읽을 수 없는 XML 이 있다, 케이스가 하나도 없다, 실패나 오류가 하나라도 있다. 개수는 머리글의 속성값이 아니라 실제 testcase·failure·error 원소를 세서 구하세요. 3단계에서 만든 /root/shard/reports 로 merge.sh /root/shard/reports 3 을 돌려 그 출력을 /root/shard/verdict.txt 에 저장하세요.
5. /root/shard/total_of.sh <조각수> <출력디렉터리> 를 만드세요. 그 조각 수로 모든 조각을 돌려 리포트를 출력 디렉터리에 남기고, 마지막에 merge.sh 의 한 줄을 그대로 출력합니다(종료 코드도 그대로 넘깁니다). 출력 디렉터리에 남는 리포트는 정확히 조각 수만큼이어야 하고, 이전 실행의 리포트가 섞이면 안 됩니다. total_of.sh 2 /root/shard/out2 와 total_of.sh 3 /root/shard/out3 을 돌려 두 줄을 /root/shard/invariant.txt 에 저장하세요. shards= 를 뺀 나머지(cases=·failures=·errors=)가 서로 같아야 합니다.
6. 공유 상태에 기대는 시험을 일부러 넣습니다. /root/shard/tests/test_config.py 는 sampleapp.SETTINGS 의 값을 바꿔 시험하되 되돌리지 않는 케이스를 두 개 두고, /root/shard/tests/test_profile.py 는 기본 설정 그대로를 기대하는 케이스를 두 개 둡니다. 전체를 한 프로세스로 돌리면 뒤 시험이 실패하고, 그 시험만 혼자 돌리면 통과하는 것을 확인하세요. 이어서 total_of.sh 를 조각 수 2·3·4·5 로 돌려 조각 수에 따라 총 판정이 갈리는 것을 보고, 오염시킨 시험 id 를 첫 줄, 피해를 본 시험 id 를 둘째 줄로 /root/shard/order-dep.txt 에 적으세요. 마지막으로 두 시험 모두 sampleapp.SETTINGS 를 계속 쓰면서도 어떤 순서로 돌려도 통과하도록 고치세요(시험을 지우거나 공유 설정을 안 쓰는 것으로 피하지 마세요).
7. /root/shard/timings.sh <리포트디렉터리> 를 만들어 리포트의 testcase 에서 <시험id> <초> 를 이름 순으로 내게 하세요. 이것으로 지금 시험 전부의 기록을 /root/shard/timings.txt 에 만듭니다(조각 하나로 전부 돌린 리포트를 쓰면 됩니다). 그다음 /root/shard/balance.sh <시간기록파일> <조각수> 를 만드세요 — 긴 것부터 그때그때 가장 가벼운 조각에 넣는 방식(LPT) 으로 나누고 <조각번호> <시험id> 를 한 줄씩 냅니다(모든 시험이 정확히 한 번씩 나와야 합니다). 마지막으로 3조각 기준으로 2단계의 해시 배분과 이 균형 배분의 가장 무거운 조각의 합을 재어 /root/shard/balance.txt 에 두 줄로 적으세요: equal <초> 와 balanced <초>.
8. /root/shard/order_fast_first.sh <시간기록파일> <시험목록파일> 을 만드세요. 목록을 기록된 소요 시간 오름차순(같으면 이름 순)으로 내고, 기록이 없는 시험은 0초로 보아 맨 앞에 둡니다. 그다음 /root/shard/gate.sh <조각수> [시험디렉터리] 를 만드세요(시험 디렉터리 기본값은 /root/shard/tests). 목록을 만들고 조각으로 나누고 조각마다 빠른 것부터 돌린 뒤 합쳐, merge.sh 의 한 줄과 종료 코드를 그대로 냅니다. 리포트는 자기 임시 디렉터리에만 남기고 /root/shard 에 XML 을 남기지 않습니다. bash /root/shard/gate.sh 3 의 출력을 /root/shard/gate.txt 에 저장하세요.
참고
- 작업 디렉터리는
/root/shard로 통일합니다. 학생이 만드는 도구는 모두 리포트 디렉터리나 시험 디렉터리를 인자로 받게 설계했습니다 — 그래야 임시 사본에서 다시 돌려 볼 수 있습니다. - 이 파드에서는 컨테이너를 띄울 수 없습니다(seccomp 가 사용자 네임스페이스를 막습니다).
podman run·podman build·buildah를 쓰지 않습니다. 쓸 것은 bash · python3.12 표준 라이브러리 · jq · sha256sum 입니다.bc·make·pytest는 없습니다. - 흔한 실수: 목록의 줄 번호나 배열 색인으로 나누는 것. 시험 하나가 늘거나 줄면 뒤가 전부 밀려 '조각 N 에서 실패' 라는 기록이 무의미해집니다.
- 흔한 실수: 합치기가 리포트 파일 수를 세지 않는 것. 조각 하나가 죽으면 파일이 아예 없으므로, 남은 리포트만 보면 전부 초록입니다.
- 흔한 실수: JUnit XML 머리글의
failures속성만 믿는 것. 그 값은 리포트를 쓴 쪽이 적은 숫자라, 원소를 직접 세는 편이 안전합니다. - [Continuous Integration (10분 빌드)](https://martinfowler.com/articles/continuousIntegration.html) · [GitHub Actions: matrix 로 작업 나누기](https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs) · [GitLab CI: job artifacts](https://docs.gitlab.com/ci/jobs/job_artifacts/)
단계 8개
- 시험 묶음을 만들고 한 번에 돌려 기준을 잡는다
- 같은 이름이면 언제나 같은 조각으로 가게 만든다
- 조각을 돌려 기계가 읽는 결과로 남긴다
- 합치기 전에는 판정이 없다
- 조각 수를 바꿔도 총 판정은 같아야 한다
- 혼자 돌면 통과하던 시험이 같이 돌자 무너진다
- 기록된 시간으로 다시 나누니 가장 무거운 조각이 가벼워졌다
- 나누고 돌리고 합치는 일을 명령 하나로 묶는다