단위테스트 도입 - TDD, 단위 테스트, fixture
2017년 03월 29일

서론

올바른 소프트웨어 개발에 있어서 단위 테스트는 반드시 필요하다고 생각합니다. 그러나 의외로 많은 개발자와 개발팀들이 적용의 필요성을 느끼면서도 개발 프로세스에 단위 테스트를 녹여넣는 것에 실패하곤 합니다.

단위 테스트의 필요성

때는 2017년 3월쯤, "카링" 프로젝트 초기 api 설계를 할 때입니다. 이 당시에는 개발을 하며 단위 테스트를 수행하지 않았습니다. 그러던 와중 비즈니스 로직 및 모델 단이 복잡해짐에 따라 이 문제점을 해결하기 위해 단위 테스트를 도입하면 어떨까 하는 생각이 들었지만 이전과 같은 한계에 부딫혔습니다.

단위테스트 vs 데이터베이스

그 한계란 바로 데이터베이스입니다. 원하는 테스트는 실제 코드 로직을 타면서 언제나 실행해도 같은 결과를 보장, 소위 "멱등성"을 지녀야 한다고 생각했는데, 모델은 데이터를 변조시키기 때문에 그렇지 못합니다. 간단한 예로 CRUD에서 Read 기능 개발을 할 때의 경우는 원하는 결과가 안나오면 수정 후 다시 실행시켜도 문제 없습니다. Read는 데이터를 변조시키지 않기 때문이다. 그러나 나머지의 경우는 아닙니다.

"카링" 기능의 예를 들어보겠습니다. 매장 유저는 기간 내 노출되는 키워드들을 구매하고 연장할 수 있습니다. 만약 기간 연장 기능 개발 시 버그를 발견하였다고 가정하겠습니다. "기존에 있는 '세차' 키워드를 1달 연장 시 2달이 연장"되는 버그가 있다고 가정합니다. 버그를 발견하기 위해 코드를 실행 시키는 순간 데이터는 변조됩니다. 만약 버그를 발견하지 못할 시 같은 데이터 state를 확보해야 하고 이 과정에 행이 걸릴 수록 개발-테스트의 이터레이션 속도가 줄어들게 되고 개발자의 피로감도 증가하게 됩니다.

팀 내에서 당시 생각해낸 방법은 local에 테스팅DB와 yml 기반 fixture를 각각 만들어 둔 후

"실DB에서 스키마만 테스팅 DB로 덤프 → fixture를 테스팅DB에 로드 → ci-phpunit-test 기반 테스트

의 과정을 거치는 것입니다. 매 테스트마다 mysqldump export, import 를 거치면 느리지 않을까 생각했는데 스키마만 덤프할 경우에는 꽤 빠릅니다.

어떻게 보면 무식한 방법이라고 생각할 수도 있겠지만 단순한 접근이 답일 때도 있습니다. 어쨌든 결과적으로 데이터를 변조하는 테스트가 있다고 해도 계속 실행해도 같은 결과가 나옵니다. 또한 데이터 조회 결과를 test double 등으로 저장했다가 가져쓰는게 아닌 실제 DB단까지 갔다 온 데이터이므로 신뢰성 및 test double 관리 이슈가 사라집니다.

이리하여 걱정없이 테스트를 실행해 볼 수 있게 되므로 개발자가 더욱 대담해지게 되고, 비즈니스 로직 개발에 집중할 수 있게 되었습니다.

Fixture Editor

위에서 언급한 yml 기반 fixture는 말 그대로 db에서 dump해와 파일로 따로 관리하는 모델 정보입니다.

f9 fixture import stores id=100 # SELECT * from stores from where id = 100 을 통해 조건에 맞는 row들을 불러와 yml로 저장
# fixtures/stores/100.yml id:100 name:"송도카센터" categoryId:34 phoneNumber:"01011112222"

이슈가 있다면 실DB에 스키마 변경이 있을 경우 해당 fixture들을 다시 로드해야 한다는 점이고, 한 눈에 픽스쳐들을 보기 힘들다는 점입니다.

그리하여 이 fixture 자체를 관리할 수 있는 엑셀과 비슷한 형태의 UI를 form 형태로 간단히 만들어 조회 및 저장, save 시 import를 일괄적으로 수행하여 스키마 싱크가 가능하도록 했습니다.

한계점 발견

결국 스키마 싱크가 완전 자동화가 되지 않았고, fixture가 git을 통해 소스로 공유되다보니 permission 이슈, 혹은 충돌나는 이슈도 많았습니다. 정확히는 fixture 값을 수정하는 케이스보다는 스키마를 변경하고 fixture 싱크를 돌린 상황이 많습니다.

또한 relation이 있는 경우 해당 relation을 찾아가 import 해주는 과정이 번거롭습니다. 예를 들어 위 케이스의 경우 id=100인 stores만 import하면 테스트가 정상적으로 작동하지 않습니다. 왜냐하면 id=34인 categories 가 import 되지 않았을 수도 있기 때문입니다. 이런 경우 직접 찾아가며 해당하는 fixture를 모두 import 시켜주어야 한다는 이슈가 있습니다.

Fixture 자체도 DB로 관리할까?

  1. diff 조회로 스키마 체크 개선
  2. relation scanning 기반 import 개선

레이블그룹에서 사용하던 기존의 fixture는 파일 베이스 이기 때문에 파일 자체의 permission, DB의 schema가 변했을 때 관리에 이슈가 있었습니다. 그렇다고 DB 베이스로 하기에는 싱크 과정이 복잡할 것으로 당시에는 판단했습니다.

그 무렵 node.js로 플랫폼 전환을 하게 되면서 기존의 php script에서 node.js 기반으로 새로운 fixture-manager를 만드는 업무를 맡게 되었는데, npm 기반의 라이브러리가 있나 리서치하게 되었는데 이런 라이브러리가 있었습니다.

dbdiff

두 데이터베이스의 스키마를 조회하여 그 차이를 alter table 쿼리로 만들어 리턴하는 라이브러리였습니다.

var dbdiff = require('dbdiff') dbdiff.describeDatabase(connString) .then((schema) => { // schema is a JSON-serializable object representing the database structure }) var diff = new dbdiff.DbDiff() // Compare two databases passing the connection strings diff.compare(conn1, conn2) .then(() => { console.log(diff.commands('drop')) }) // Compare two schemas diff.compareSchemas(schema1, schema2) console.log(diff.commands('drop'))

기존 방식은 두 데이터베이스를 mysqldump 를 통해 create table 쿼리를 만들어 두 해시값을 비교해 다를 경우 새로 dump하는 방식이었는데, 이 방식보다 훨씬 빠르게 스키마 체크 및 업데이트가 가능하게 되었습니다.

또한 소스 내에서 공유하는 방식 대신 서버에서 fixture를 관리 할 수 있도록 하기 위하여 서버에 <DB이름>_fixture 라는 새로운 DB를 만들 수 있도록 하여 단위 테스트 실행 시 마다 db-diff로 체크 및 업데이트하도록 했습니다. alter table 기반이므로 새 필드가 추가되면 기본값으로, 삭제될 경우 삭제되기 때문에 그 자체로 이슈는 없습니다. 그 후 로컬에 있는 fixture에는 mysqldump를 통한 완전 복사(캐싱) 후 임포트 하는 식으로 했습니다. depth가 많아진듯 보이지만 실제로는 더 안정적으로 픽스쳐를 관리 할 수 있게 되었습니다.

또한 relation 관련 import 이슈가 단순히 해결되지 않는 이유는 fk를 사용하지 않고 컨벤션에 의존하게 되어 자동화된 스키마 분석이 불가능하기 때문입니다. 그리하여 테이블, 필드명을 모두 가져와 컨벤션을 기반으로 파싱하여 스키마 탐색 결과를 확보했습니다.

class Store extend Model { var $id; var $category_id; var $name }
class Category extend Model { var $id; var $name }

예를 들어 위 케이스의 경우 category_id는 Category 모델과 연결된 fk입니다. 실제로 fk는 걸지 않았지만 index만 걸고 '테이블단수명_id'로 끝나는 필드는 fk라는 컨벤션을 유지한 상황입니다.

이러한 정보를 바탕으로 Store에서 한 row를 import 후, 그 row에 category_id의 값으로 Category 테이블에서 추가적으로 데이터를 가져옵니다. 즉 한 특정 모델에서 데이터를 import 해도 many to one 관계에 있는 테이블도 recursive하게 가져올 수 있도록 처리했습니다. 반대로 one to many한 관계에 있는 데이터도 optional하게 가지고 올 수 있도록 처리했습니다.