개념
왜 임베디드에서 단위 테스트가 어려운가
레슨 1 에서 MCU·펌웨어·드라이버·RTOS 계층을 봤다. 그 구조에서 단위 테스트가 어려운 이유는 코드가 하드웨어를 직접 만지기 때문 이다.
void pump_control(void) {
if (ADC1->DR > PRESSURE_LIMIT) { /* 레지스터를 직접 읽는다 */
GPIOA->BSRR = PUMP_OFF_BIT; /* 레지스터에 직접 쓴다 */
error_count++;
}
}이 함수를 호스트(개발 PC) 에서 실행하면 ADC1 주소가 존재하지 않아 터진다.
그래서 많은 팀이 "임베디드는 단위 테스트가 안 된다" 고 결론 내리고,
전부 대상 하드웨어에서 통합 시험으로 확인한다. 그러면 세 가지를 잃는다.
- 빠른 되먹임 — 한 함수의 경계 조건을 확인하려고 빌드·플래시·수동 조작을 거친다
- 만들기 어려운 조건 — 센서 값이 범위를 벗어난 순간, 오류 카운터가 넘치는 순간을 실제 하드웨어로 만들려면 장비와 시간이 든다
- 구조 커버리지 — 어느 분기가 한 번도 실행되지 않았는지 알 수 없다
단위 테스트가 안 되는 것이 아니다. 코드가 그렇게 쓰여 있어서 안 되는 것 이다. 그래서 이 레슨의 절반은 시험 기법이 아니라 경계 설계 다.
경계를 긋는다 — 로직과 하드웨어 접근을 분리
위 함수를 두 조각으로 나눈다.
/* 1) 하드웨어 접근 — 얇게. 로직이 없다 */
uint16_t hal_adc_read(void) { return ADC1->DR; }
void hal_pump_set(bool on) { GPIOA->BSRR = on ? PUMP_ON_BIT : PUMP_OFF_BIT; }
/* 2) 로직 — 하드웨어를 모른다. 호스트에서 돌아간다 */
void pump_control(uint16_t pressure, pump_state_t *st) {
if (pressure > PRESSURE_LIMIT) {
st->pump_on = false;
st->error_count++;
}
}분리의 기준은 "레지스터 주소가 나오는가" 다. 나오면 1번 쪽, 안 나오면 2번 쪽. 그리고 1번은 얇게 유지한다 — 한 줄짜리 함수들이 된다. 1번 쪽은 호스트에서 시험할 수 없고, 대상에서 확인한다.
이 분리가 주는 것은 셋이다.
| 분리 전 | 분리 후 | |
|---|---|---|
| 로직 시험 | 대상 하드웨어 필요 | 호스트에서 즉시 |
| 조건 만들기 | 실제 센서 조작 | 인자로 값을 넣는다 |
| 커버리지 | 측정 어려움 | 호스트 도구로 측정 |
얇은 계층은 시험하지 않고 검토한다. hal_adc_read 한 줄에 단위 시험을
붙이는 것은 의미가 없다. 대신 레지스터 주소와 비트 정의가 데이터시트와 맞는지
를 코드 검토로 확인하고, 대상에서 한 번 확인한다. 이 구분을 분명히 해 두면
"HAL 도 100% 커버해야 하나" 하는 소모적 논쟁이 사라진다.
그런데 기존 코드는 이미 섞여 있다
현실의 작업은 백지에서 시작하지 않는다. 섞여 있는 코드를 한 번에 다 고치지 않고 점진적으로 분리하는 순서가 있다.
- 시험하고 싶은 함수를 하나 고른다. 결함이 자주 나는 곳, 복잡한 분기가 있는 곳, 곧 바꿀 곳
- 그 함수 안의 하드웨어 접근만 추출 한다. 함수 이름을 바꾸지 않는다
- 추출한 접근을 교체 가능하게 만든다(아래 세 방법 중 하나)
- 단위 시험을 붙이고, 그 뒤에 리팩터링 한다
4번 순서가 중요하다. 시험 없이 리팩터링하면 동작이 바뀐 것을 모른다. 먼저 시험을 걸 수 있는 최소한의 변경만 하고, 시험이 생긴 뒤에 구조를 고친다.
하드웨어 접근을 교체하는 세 방법
| 방법 | 어떻게 | 장점 | 단점 |
|---|---|---|---|
| 링크 시 교체 | 대상용 hal_xxx.c 와 시험용 hal_xxx_fake.c 를 따로 빌드 | 코드 변경 없음 | 시험마다 동작을 바꾸기 어렵다 |
| 함수 포인터 주입 | 구조체에 함수 포인터를 담아 넘긴다 | 시험마다 바꿀 수 있다 | 간접 호출 비용, 코드 변경 필요 |
| 모킹 도구 생성 | 헤더에서 모의 함수를 자동 생성 | 호출 횟수·인자 검증까지 | 도구 의존, 빌드 설정이 늘어난다 |
링크 시 교체 가 가장 먼저 시도할 방법이다. 코드를 건드리지 않고 빌드 구성만 바꾸므로 기존 코드에 적용하기 쉽다. 시험용 구현에 전역 변수로 반환값을 설정 해 두면 시험마다 조건을 바꿀 수 있다.
/* hal_adc_fake.c */
uint16_t fake_adc_value; /* 시험이 설정한다 */
uint16_t hal_adc_read(void) { return fake_adc_value; }함수 포인터 주입 은 설계를 바꾸므로 새로 쓰는 코드에 쓴다. 다만 성능이 빠듯한 경로에서는 간접 호출 비용을 고려한다.
모킹 도구 는 "호출되었는가, 몇 번, 어떤 인자로" 를 검증해야 할 때 쓴다. C 쪽에서 쓰이는 단위 시험·모킹 도구 묶음이 공개되어 있고, 빌드 체계와 함께 쓰는 형태가 일반적이다. 도구 이름·버전·설정 방법은 각 프로젝트의 공식 문서에서 확인한다(원문 확인) — 이 영역은 도구 선택이 조직마다 다르다.
모킹을 어디까지 할 것인가 — 가짜와 모의
구분해 두면 설계가 깔끔해진다. 모듈 10 레슨 3 에서 다룬 구분이 임베디드에서는 이렇게 쓰인다.
가짜(fake) — 동작하는 대체 구현. 플래시 저장을 메모리 배열로 흉내 내는 것. 상태를 갖고 읽고 쓴 결과가 일관된다. 저장·로드 로직을 시험할 때 쓴다.
모의(mock) — 호출을 검증하는 대체 구현. "워치독을 매 주기 재설정했는가", "오류 시 안전 핀을 내렸는가" 처럼 호출 자체가 요구사항 일 때 쓴다.
임베디드에서 모의가 특히 유용한 경우 가 있다. 안전 관련 요구는 종종 "무엇을 한다" 가 아니라 "반드시 호출한다" 의 형태다. 예 — 고장을 감지하면 안전 상태 진입 함수를 호출해야 한다. 그 호출 여부를 검증하는 것이 모의의 일이고, 이것은 대상 하드웨어로 확인하기 어렵다.
과하게 모킹하면 시험이 구현을 따라 하게 되어, 리팩터링마다 시험이 깨진다. 기준은 하나다 — 요구사항이 "호출" 을 말하면 모의, "결과" 를 말하면 가짜.
임베디드 특유의 어려움 네 가지
1) 전역 상태. 임베디드 C 코드는 전역·정적 변수를 많이 쓴다. 시험 사이에 상태가 남으면 실행 순서에 따라 결과가 달라진다.
해결은 모든 시험 앞에서 상태를 초기화 하는 것이다. 초기화 함수가 없으면
만든다 — module_init() 같은 진입점이 없는 모듈은 그 자체로 시험하기 어렵다.
정적 변수에 접근할 수 없으면 시험 빌드에서만 노출 하는 방법이 쓰인다.
2) 인터럽트. 인터럽트 핸들러는 호스트에서 "발생" 시킬 수 없다. 해법은 핸들러를 함수로 직접 호출 하는 것이다. 핸들러가 얇고(플래그 설정·큐 적재만) 실제 처리가 별도 함수에 있으면, 그 함수를 호스트에서 시험할 수 있다.
핸들러가 얇아야 하는 것은 실시간 설계의 원칙이기도 하다 — 레슨 9 에서 다시 본다. 시험 가능성과 실시간 설계가 같은 방향을 가리키는 드문 경우 다.
3) 시간과 타이머. delay_ms() 가 코드에 박혀 있으면 시험이 실제로 기다린다.
시간을 주입 한다 — 현재 시각을 반환하는 함수를 HAL 로 빼고, 시험에서 그 값을
전진시킨다. 그러면 "24시간 뒤" 조건도 즉시 시험된다.
/* 시험에서 */
fake_tick = 0; logic_step();
fake_tick = 86400000; logic_step(); /* 하루 경과를 즉시 만든다 */이것은 레슨 9 의 타이머 분석과 다른 축이다. 여기서는 논리적 시간 경과 를 다루고, 거기서는 실제 실행 시간 을 다룬다.
4) 자료형과 비트 폭. 호스트의 int 가 32비트이고 대상이 16비트면,
오버플로가 나는 경계가 다르다. 호스트 시험이 통과한 코드가 대상에서 넘친다.
해결은 고정 폭 자료형을 쓰는 것(uint16_t 등) 과, 경계 시험에서
대상 폭을 기준으로 값을 고르는 것 이다. 그리고 호스트 컴파일에서 경고를
최대한 켜 두면 암묵적 변환을 잡아낸다. 모듈 63 레슨 4 의 여섯 축 중 "자료형·정렬"
이 이 문제의 일반형이다.
호스트 결과로 보장할 수 없는 것
이것을 분명히 해 두는 것이 이 레슨의 책임이다. 호스트 단위 시험이 통과했다고 대상에서 동작한다는 뜻이 아니다.
| 호스트에서 확인 가능 | 호스트에서 확인 불가 |
|---|---|
| 분기·계산·상태 전이 로직 | 실제 실행 시간(레슨 9) |
| 경계 조건과 오류 처리 | 레지스터 주소·비트 정의의 정확성 |
| 호출 여부(모의로) | 인터럽트 우선순위·중첩 동작 |
| 구조 커버리지 | 스택 사용량, 메모리 배치 |
| 상태 머신의 금지 전이 | 컴파일러 최적화의 영향 |
그래서 보고서에 쓸 문장이 정해진다. "호스트 단위 시험 N 건 통과" 는 로직 검증의 증거이고, 대상 동작의 증거가 아니다. 어느 결과를 어느 수준의 증거로 쓸 수 있는지는 모듈 63 레슨 4 에서 다룬 원칙 그대로다.
반대로 대상에서만 시험하면 놓치는 것 도 분명하다 — 위 왼쪽 열의 대부분이 대상에서는 만들기 어렵다. 두 층을 함께 쓰는 것이 답이고, 무엇을 어디서 확인할지 배분표 를 만드는 것이 QA 의 일이다.
커버리지를 쓰는 방식
호스트 빌드에서 커버리지를 측정할 수 있게 되면 분기 누락 이 보인다. 다만 모듈 63 레슨 3 에서 다룬 원칙이 여기도 적용된다 — 커버리지는 시험 설계의 기준이 아니라 사후 측정 이다. 요구에서 시험을 만들고, 남은 빈 곳을 네 가지로 분류한다(시험 부족 / 요구 누락 / 실행 불가 코드 / 의도적 비활성).
임베디드에서 특히 자주 나오는 빈 곳이 방어적 코드 다. "일어날 수 없는" 기본 분기, 하드웨어 오류 경로, 포인터 널 검사. 이것들은 의도적으로 실행되지 않는 코드 로 분류하고 근거를 적는다. 기능안전 규격이 걸린 프로젝트라면 이 분류와 근거가 산출물이 된다(모듈 35, 모듈 63 레슨 3).
실무에서 왜 필요한가
임베디드 조직에서 "단위 테스트를 도입하자" 는 제안이 실패하는 전형적 경로가 있다. 도구를 고르고 설정하고 몇 개 시험을 쓰다가, 기존 코드에 붙지 않아서 멈춘다. 하드웨어 접근이 로직과 섞여 있어 호스트에서 컴파일조차 되지 않는다.
그래서 이 작업의 실제 순서는 도구가 아니라 경계 다. 함수 하나를 골라 하드웨어 접근을 추출하고 링크 교체로 시험을 붙여 보이면, 그 하나가 다음을 설득한다. 작게 시작해 보여 주는 것 이 조직에서 이 변화를 만드는 유일한 방법이다.
QA 가 기여하는 자리도 분명하다. "이 코드는 시험할 수 없습니다" 를 테스트 가능성 지적으로 올리는 것 이다. 모듈 9 레슨 9 의 테스트 가능성 판단이 임베디드에서는 이 형태로 나타난다. 그리고 그 지적은 품질만이 아니라 실시간 설계와 같은 방향 을 가리키는 경우가 많다 — 얇은 인터럽트 핸들러, 주입된 시간, 분리된 계층.
가장 큰 실질적 소득은 만들기 어려운 조건의 시험 이다. 센서 고장, 값 범위 초과, 카운터 오버플로, 24시간 경과. 이것들을 실제 하드웨어로 만들려면 장비와 시간이 들고, 어떤 것은 아예 만들 수 없다. 호스트에서는 인자 하나다.