함수마다 테스트가 있어도 서로 연결하면 다른 문제가 생길 수 있다. 승인한 문서를 수정했는데 이전 승인이 살아 있거나 저장 실패 뒤 화면이 최신이라고 보이면 개별 성공만으로 설명하기 어렵다.

나는 이번에 만든 네 공개 모듈의 코드를 그대로 모아 로컬 회귀 검사 7개를 실행했다. 실제 회사 자료나 운영 ERP를 쓰지 않고 임시 SQLite와 고정 값으로 관계를 확인했다.

기능 이름보다 실패하면 잃는 사실을 고른다

검사 대상은 화면 단추 개수가 아니라 잃으면 안 되는 관계다. 내용 수정 뒤 예전 승인으로 실행할 수 없어야 하고, 중복 요청으로 이력과 수량이 두 번 바뀌지 않아야 한다. 저장 거래를 중간에 실패시키면 조회 내용과 이력, 후속 알림 행이 모두 이전 상태여야 한다. 사용자가 믿을 사실을 기준으로 시나리오를 정했다.

이것을 전체 ERP 기능을 모두 검사했다는 주장으로 쓰지 않는다. 실제 화면과 API, 운영 권한과 외부 전송은 연결하지 않았다. 검사에서는 고정된 신뢰할 Actor를 사용하고 알림도 outbox 행만 확인한다. 합성 시나리오의 통과와 실제 서비스의 완전성은 다르므로 어떤 경계를 걸었는지부터 기록했다.

저장 결과를 다른 읽기 코드로 확인한다

중복 저장 검사는 execute의 성공 값만 보지 않는다. 실제 임시 DB에서 다시 읽은 문서를 조회 스냅샷으로 바꾸고 전체 출력과 요약 수량을 대조한다. 이력도 한 건인지 확인한다. 저장 함수가 만든 결과를 그대로 다시 읽어 성공이라고 부르는 대신 다른 경로의 업무 값을 연결한 것이다.

권한 범위 검사에서는 다른 회사의 읽기가 비어 있고 변경이 거절된 뒤 원래 회사의 수량은 그대로인지 확인한다. 다만 빈 읽기만 보고 매출이 0이라고 계산해도 된다는 뜻은 아니다. 조회 권한 실패와 실제 자료 없음의 의미는 서비스에서 분리해야 한다. 테스트 제목도 그 오해를 남기지 않도록 정했다.

화면과 자료 버전도 같이 흔들어 본다

변환 미리보기의 새 schema를 옛 코드 호환성 함수에 넣으면 blocked가 나와야 한다. 검토를 끝낸 제안의 기준 revision을 바꾼 뒤 적용하면 실패해야 한다. 그 실패를 저장 응답 모델에 연결해도 사용자가 새로 입력한 초안과 dirty 표시는 남아야 한다. 고정 오류 코드 기록은 원래 초안을 포함하지 않는다.

다른 검사에서는 이전 스냅샷의 커서를 바뀐 내용의 새 스냅샷에 넣어 거절하는지 확인한다. 기존 요약과 새 요약은 각각 자기 기준의 수량을 유지한다. 이 연결은 합성 입력의 변화를 재현하는 것이며 실제 동시 사용자의 요청과 통신 지연을 발생시킨 부하 시험은 아니다. 증거가 닿는 범위를 구분했다.

실행한 소스와 전체 테스트를 함께 남긴다

아래 코드는 네 패키지의 core Python 파일과 같은 폴더에 두고 실행하는 unittest 파일이다. 공개 모듈에서 받은 atomic_workflow.py, snapshot_query.py, interaction_flow.py와 versioned_contracts.py가 필요하다. 표준 라이브러리만 사용하며 실행 때 만든 임시 합성 DB는 테스트가 끝나면 정리한다. 운영 파일 경로를 넘기는 인자는 없다.

실제 실행에 쓴 각 소스와 테스트 파일의 SHA-256을 로컬 검사 보고서에 남겼다. 이번에는 7개가 모두 통과했다. 아래 코드는 그때의 전체 테스트이며 성공 숫자만 꾸민 화면이 아니다. 기록을 다시 읽을 수 있도록 남기지만 브라우저나 실업무 결과를 확인한 것으로 보고하지 않는다.

코드와 구현 살펴보기

네 공개 모듈을 연결하는 전체 합성 테스트

"""Place the four published core Python files beside this test. Synthetic only."""
import unittest,tempfile
from pathlib import Path
from atomic_workflow import WorkflowLab,Actor,command
from snapshot_query import make_snapshot,summary,page,export_rows
from interaction_flow import save_state,edit,begin_save,save_reply,save_status
from versioned_contracts import migration_preview,compatibility,proposal,review,apply_reviewed,error_record

class CrossWorkflowTests(unittest.TestCase):
    def setUp(self):
        self.temp=tempfile.TemporaryDirectory();self.lab=WorkflowLab(Path(self.temp.name)/'synthetic.sqlite')
        self.staff=Actor('A','USER_A','staff');self.reviewer=Actor('A','REVIEWER','coordinator')
    def tearDown(self):self.temp.cleanup()
    def snapshot(self):
        rows=self.lab.read(self.staff)
        return make_snapshot([{'company_id':r['company_id'],'id':r['id'],'tick':0,'quantity':r['quantity'],'status':'draft'} for r in rows],{'company_id':'A'},rows[0]['version'])
    def test_approval_does_not_survive_a_content_edit(self):
        self.assertTrue(self.lab.execute(self.staff,'submit',command('submit',0))['ok'])
        self.assertTrue(self.lab.execute(self.reviewer,'approve',command('approve',1))['ok'])
        self.assertTrue(self.lab.execute(self.staff,'change',command('edit',2,quantity=7))['ok'])
        result=self.lab.execute(self.reviewer,'execute',command('execute',3))
        self.assertEqual(result['reason'],'not_approved');self.assertEqual(summary(self.snapshot())['quantity'],7)
    def test_duplicate_request_changes_export_once(self):
        cmd=command('edit',0,quantity=8);self.lab.execute(self.staff,'same',cmd);self.assertTrue(self.lab.execute(self.staff,'same',cmd)['replayed'])
        result=self.snapshot();self.assertEqual(summary(result)['quantity'],8);self.assertEqual(len(export_rows(result)),1);self.assertEqual(len(self.lab.history(self.staff)),1)
    def test_failed_transaction_leaves_read_and_side_effects_unchanged(self):
        before=self.snapshot()
        with self.assertRaises(RuntimeError):self.lab.execute(self.staff,'fail',command('edit',0,quantity=9),fail_before_commit=True)
        self.assertEqual(self.snapshot().fingerprint,before.fingerprint);self.assertEqual(self.lab.history(self.staff),[]);self.assertEqual(self.lab.outbox(self.reviewer),[])
    def test_scope_denial_is_not_a_zero_business_total(self):
        self.assertEqual(self.lab.read(Actor('B','USER_A','staff')),[])
        self.assertFalse(self.lab.execute(Actor('B','USER_A','staff'),'cross',command('edit',0,quantity=9))['ok'])
        self.assertEqual(summary(self.snapshot())['quantity'],6)
    def test_data_preview_changes_code_compatibility(self):
        original={'schema':1,'rows':[{'id':'A','qty':6}]};preview=migration_preview(original)
        self.assertFalse(preview['applied']);self.assertEqual(original['schema'],1)
        self.assertEqual(compatibility({'release':'old','reads':[1],'writes':1},preview['proposed']['schema'])['mode'],'blocked')
    def test_review_conflict_does_not_erase_a_new_draft(self):
        state={'company_id':'A','revision':1,'quantity':6,'memo':''};p=proposal(state,{'quantity':7},'agent');receipt=review(state,p,'human','reviewer')
        ui=edit(save_state(),'earlier');ui,request=begin_save(ui);ui=edit(ui,'newer unsent draft')
        with self.assertRaises(ValueError):apply_reviewed({**state,'revision':2},p,receipt)
        ui=save_reply(ui,request['sequence'],False)
        self.assertEqual(ui['draft'],'newer unsent draft');self.assertTrue(save_status(ui)['dirty'])
        self.assertEqual(error_record('VERSION_CONFLICT','save','trace_1')['code'],'VERSION_CONFLICT')
    def test_changed_snapshot_rejects_earlier_cursor(self):
        rows=[{'company_id':'A','id':'A','tick':1,'quantity':1,'status':'draft'},{'company_id':'A','id':'B','tick':2,'quantity':2,'status':'draft'}]
        first=make_snapshot(rows,{'company_id':'A'},1);cursor=page(first,1)['next_cursor'];rows[1]['quantity']=3;second=make_snapshot(rows,{'company_id':'A'},2)
        with self.assertRaises(ValueError):page(second,1,cursor)
        self.assertEqual(summary(first)['quantity'],3);self.assertEqual(summary(second)['quantity'],4)

if __name__=='__main__':unittest.main()

회귀 목록은 바뀐 책임에 맞춰 자란다

앞으로 기능을 추가하면 실패했을 때 깨질 관계를 먼저 찾아 이 목록에 더할 수 있다. 기존 함수를 한 줄씩 그대로 흉내 내는 테스트보다 승인과 읽기, 검토와 초안처럼 다른 책임의 약속을 함께 확인하는 편이 도움이 된다고 판단했다. 이번 일곱 개를 앞으로도 고정된 전체 목록으로 제한하지 않는다.

실제 서비스에 적용하면 API 권한, 파일 보관과 화면 상호작용, 외부 전송 결과의 재확인도 필요할 수 있다. 이번에 하지 않은 검사는 남겨 두어야 한다. 공유한 네 모듈과 테스트는 업체 자료가 없는 독립 예제다. 나는 수정했을 때 안심해도 된다는 말보다 어떤 관계를 확인했고 무엇을 더 확인해야 하는지 공개하고 싶었다.