난이도 : 중급
Bruce Tate, CTO, RapidRed
2007 년 10 월 23 일
테스팅은 Ruby on Rails 커뮤니티에서 입지를 굳건히 하고 있습니다. Rails 스택부터 커버리지용 RCov, 테스트케이스를 강화시킬 수 있는 Mocha와 FlexMock에 이르기까지 많은 툴들이 있습니다. 하지만, 툴들마다 다양한 전략이있습니다. 여러 가지 기본적인 테스팅 전략의 장단점을 배워봅시다.
Rails플랫폼의 가장 뚜렷한 특징 중 하나는 Ruby 언어 그 자체이다. 동적으로 유형화 된 언어로서, Ruby는 상당히 유연하고,편리하며, 강력하다. 하지만, 단점도 있다. 동적으로 유형화 된 언어들은 비교적 일반적인 유형 에러와 오자를 포함하여 특정종류의 에러를 잡을 수 있는 컴파일러가 없다. 동적으로 유형화 된 객체 지향 언어 사용자들은 테스트에 대한 필요성을 일찌감치배웠다.
Ruby on Rails 커뮤니티는 미국이 아메리칸 아이돌(American Idol)을 포용하듯
테스팅을 수용하고 있다. 매우 규칙적으로, 테스트 케이스 결과표를 검사한다. Ruby 개발자는 테스팅에 대해 많은 이야기 한다.그들의 블로그도 테스트에 대한 이야기이다. 심지어 전화 투표가 아닌 오픈 소스 프레임웍에 기여함으로써 후방에서도 참여하고 있다.
테스팅이 없다면, Ruby 애플리케이션은 코드 라인 당 에러 발생 빈도가 훨씬 높아진다. 테스팅을 한다면, 동적으로 유형화된언어에 어느 정도의 효과가 있다. 이 글에서는, 테스트를 해야 하는지 여부, 테스팅이 중요한 이유를 매니저에게 설득하는 방법같은 기본적인 주제로 여러분을 따분하게 만들지 않을 것이다. 여러분은 이미 테스트를 하고 있을지도 모른다. 대신, 모든 Ruby프로젝트 리더가 해야 하는, 보다 미묘한 테스팅 결정들을 상세히 살펴 보도록 하겠다. 테스팅 범위를 측정하는 방법, 테스팅 분량등을 설명하겠다. 기본적인 테스팅 기술과 새로운 mock 프레임웍을 비교할 것이다. 지루한 튜토리얼을 제공하는 대신,ChangingThePresent.org(참고자료)를 구현하는데 사용했던 기술을 예제를 통해 설명하겠다. 다양한 기술들의 장점과 단점을 알 수 있을 것이다.
기존 Rails 테스팅
Rails프레임웍은 자체적으로 매우 강력한 테스팅을 갖고 있다. 적은 노력으로 반복적인 데이터베이스 설치를 지정할 수 있고, 시뮬레이트된 HTTP 메시지를 웹 애플리케이션에 보낼 수 있으며, 세 가지 종류의 테스트, 단위 테스트(unit test), 기능테스트(functional test), 통합 테스트(integration test)를 수행한다.
단위 테스트
단위 테스트는 Rails 모델 코드와 가끔씩 헬퍼를 실행한다. 단위 테스트는 모델이 구현한 대로 작동하는지, 모델에 있는 제휴들이여러분이 생각한 대로 작동하는지를 검사한다. 기억해 보면, Rails 모델은 하나의 데이터베이스 테이블을 래핑하는 객체들이다.대부분의 경우, 각 데이터베이스 컬럼은 모델에 있는 애트리뷰트이다. Rails 헬퍼는 모델, 뷰, 컨트롤러 코드를 단순화 하는함수들이다. 각 모델이나 헬퍼가 테스트를 갖고 있는지를 확인해야 한다. ChangingThePresent에서, 가장 기본적인모델에 대한 단위 테스트는 매우 얇다.
Listing 1: 기본적인 모델 테스트 require File.dirname(__FILE__) +'/../test_helper'class BannerStyleTest < Test::Unit::TestCasefixtures :banner_styles def test_associationsassert_working_associations end deftest_validation_with_incorrect_specs_should_fail bs =BannerStyle.new(:height => 10, :width => 10, :format =>'vertical_rectangle', :model_name => 'Nonprofit') assert !bs.save,bs.errors.inspect bs2 = BannerStyle.new(:height => 400, :width =>240, :format => 'vertical_rectangle', :model_name => 'Nonprofit')assert bs2.save, bs2.errors.inspect end ...end
|
Listing1에서, 두 개의 테스트를 가진 응축된 테스트 케이스를 볼 수 있다. 배너 스타일은 간단한 광고 배너를 만든다. 각각의 크기와모양은 느슨한 표준 세트에 기반하고 있다. 이 애플리케이션은 표준 테이블을 사용하여 각각의 새로운 배너가 표준에 순응하는지를확인한다. 첫 번째 테스트는 헬퍼를 사용하여 리플렉션을 통해서 BannerStyle에 대한 모든 제휴들을 실행한다. (Listing 2) 두 번째 테스트는 틀린 높이와 넓이를 가진 배너가 저장될 수 없다는 것을 확인하고, 모델이 유효 스팩을 가진 배너를 정확히 저장한다는 것을 확인한다.
Listing 2. 제휴를 테스트 하는 헬퍼 def assert_working_associations(m=nil) m ||=self.class.to_s.sub(/Test$/, '').constantize @m = m.newm.reflect_on_all_associations.each do |assoc|assert_nothing_raised("#{assoc.name} caused an error") do@m.send(assoc.name, true) end end trueend
|
Listing 2는 클래스에 대한 모든 제휴들을 실행하는 헬퍼를 보여준다. assert_working_associations 메소드는 클래스에 대한 모든 제휴들을 검사하고 모델에 제휴의 이름을 보낸다. 이러한 catch-all은 모델 당 한 줄의 코드로 모든 모델 테스트에 대한 모든 관계를 호출할 수 있다는 것을 확인한다.
기능 테스트와 통합 테스트
기능 테스트는 고립된 HTTP 요청을 통해 사용자 인터페이스를 시험한다. Rails 프레임웍은 HTTP GET과 POST 명령어를쉽게 호출할 수 있게 해주며, 테스트의 중추를 형성한다. 통합 테스트도 이와 같지만, 많은 HTTP 요청들을 계속하여 호출할 수있다. 이 테스트의 원리와 구조는 같다. Listing 3은 기본적인 기능 테스트 모습이다.
Listing 3. 간단한 기능 테스트 | require File.dirname(__FILE__) +'/../test_helper'require 'causes_controller'class CausesController; defrescue_action(e) raise e end; endclass CausesControllerTest <Test::Unit::TestCase fixtures :causes, :members, :quotes,:cause_images, :blogs, :blog_memberships def setup @controller =CausesController.new @request = ActionController::TestRequest.new@response = ActionController::TestResponse.new end def test_index get:index assert_response :success assert_template 'index' assert_not_nilassigns(:causes) assert_equal Cause.find_all_ordered.size,assigns(:causes).size end def test_should_create_blog assertCause.find(2).blog.nil? get :create_blog, :id => 2 assertCause.find(2).blog.nil? login_as :bruce get :create_blog, :id => 2assert !Cause.find(2).blog.nil? assert_equal Cause.find(2).name,Cause.find(2).blog.title end |
Listing 3에서, 테스트와 시스템 간 인터랙션이 모두HTTP GET과 POST를 통해 이루어 진다는 것을 알 수 있다. 테스트의 기본 흐름은 다음과 같다.
- 간단한 HTTP 연산을 실행한다.
- HTTP 연산이 시스템에 미치는 영향을 테스트 한다.
게다가, Listing 3의 설정 메소드는 테스팅 스카폴드를 설정하여 HTTP 호출을 시뮬레이트 한다. 테스트 스카폴드는 네트워킹과 인프라스트럭처용 요구 사항을 제거하면서, 테스트 케이스를 애플리케이션 자체로 고립시킨다.
스텁
ChangingThePresent.org의 경우, 로그인 같은 일을 쉽게 수행할 수 있도록 하는 테스트 헬퍼 메소드를 추가했다. Listing 3의 test_should_create_blog 메소드의 다섯 번째 라인에서 login_as :bruce 메소드를 볼 수 있다. 이것은 Listing 4의 헬퍼를 호출하는데, 이는 사이트 로그인 기능을 제거하면서, 멤버의 아이디를 세션에 직접 복사한다. Rails acts_as_authenticated를 사용했다면, 로그인 한 사용자가 세션 내에서 :user 키와 제휴된 값을 설정할 것이라는 것을 알 것이다.
Listing 4. 로그인 제거하기 def login_as(member) @request.session[:user] = member ? members(member).id : nilend
|
많은 개발자들은 스텁과 mock의 개념을 혼동하고 있다. 스텁은 실제 구현을 더 단순한 구현으로 대체하는 것이다. Listing 4에서, 스텁은 전체 로그인 시스템을 간단한 것으로 대체했다. 스텁의 역할은 실제를 시뮬레이트 하는 것이다. Mock은 스텁이 아니다. Mock 객체는 애플리케이션이 인터페이스를 사용하는 방식을 측정하는 게이지와 같은 것이다. 스텁에 대해 예제를 사용하여 보다 자세히 설명하겠다.
기본 개념
Rails의 기존 테스팅의 핵심을 배웠다. 더 진행하기 전에, 두 가지의 핵심적인 결정들이 필요하다. 얼마나 많이, 얼마나 빠르게 테스트할 것인가? 전체적인 테스팅 개념을 수립하게 되면, 커버리지와 속도와 관련한 장단점을 주의해야 한다.
커버리지
가장 중요한 결정 중 하나는 얼마나 많이 테스트를 할 것인가 이다. 충분히 테스트를 하지 않으면, 코드 품질을 타협하게 되고,심지어는 딜리버리 시간도 늦어지게 된다. 또한, 너무 많이 테스트 할 수도 있다. 너무 많이 테스트 하게 되면, 비즈니스에중요한 데드라인을 놓치게 된다. 테스트 분량에 대한 현명한 결정을 내리려면, 이미 어느 정도의 테스트를 수행하고 있는지를 정확히측정해야 한다. 테스팅에 있어서 가장 중요한 것 중 하나가 코드 커버리지이다.
ChangingThePresent의 경우, RCov를 사용하여 테스트 커버리지를 결정한다. 전통적인 rake 명령어를 실행하고 결과(DOT TRAIL)를 얻는다. 또한, rake test:coverage를 실행하여 Listing 5에서 보이는 것 같은 보다 완전한 리포트를 얻는다.
Listing 5. RCov를 사용하여 rake test:coverage 실행하기 807 tests, 2989 assertions, 0 failures, 0errors+----------------------------------------------------+-------+-------+--------+|File | Lines | LOC | COV|+----------------------------------------------------+-------+-------+--------+|app/controllers/address_book_controller.rb| 142 | 123 | 84.6% ||app/controllers/admin_controller.rb | 77 | 65 |93.8% ||app/controllers/advisor_admin_controller.rb | 86 | 63 | 88.9%||app/controllers/advisors_controller.rb | 52 | 42 | 100.0%|...|app/models/stupid_gift.rb | 56 | 45 | 100.0%||app/models/stupid_gift_image.rb | 10 | 10 | 100.0%||app/models/titled_comment.rb | 2 | 2 | 100.0% ||app/models/upgrade.rb| 13 | 10 | 100.0% ||app/models/upgrade_item.rb | 3 | 3 | 100.0%||app/models/validation_model.rb | 7 | 7 | 100.0%||app/models/volunteer_opportunity.rb | 137 | 129 | 93.0%||app/models/work_period.rb | 5 | 4 | 100.0%|+----------------------------------------------------+-------+-------+--------+|Total| 12044 | 10044 | 81.8%|+----------------------------------------------------+-------+-------+--------+81.8%167 file(s) 12044 Lines 10044 LOC
|
RCov는 실행하는데 훨씬 더 많은 시간이 걸리기 때문에(우리 테스트의 두 배), 모든 것에 그 명령어를 실행하지 않지만, 실행할경우에는 주어진 파일에 대한 테스트 커버리지가 어느 정도인지 정확히 알려줄 수 있다. 여전히, 브라우저에서 커버리지 파일을열어, 나의 테스트 케이스가 커버 할 코드 라인이 어떤 것인지를 정확히 본다. 그림 1은 전형적인 커버리지 리포트 예제이다.
그림 1. RCov 리포트
숫자가 있을 경우, 정확히 얼마만큼 테스트 할 것인지에 대한 대략적인 계산을 시작할 수 있다. ChangingThePresent의경우, 수시로 변하는 테스트 커버리지 통계가 나왔지만, 80%에서 85% 사이의 숫자로 정착했다. 새로운 주요 기능들은 개발중이기 때문에, 커버리지는 임시적으로 감소할 것이다. 이러한 새로운 기능을 온라인으로 가져오면, 커버리지가 늘어날 것이다.현재, 커버리지는 81.7%이다.
우리가 내놓은 대답이 여러분의 의견과 같지 않을 수 있다는 것을 명심하라.테스트 커버리지는 팀의 개발자의 경험, 애플리케이션의 복잡성, 애플리케이션의 에러에 대한 내구성, 비즈니스의 지연에 대한내구성에 따라 달라진다. 비행기 엔지니어링 애플리케이션을 구현하고 있다면, 더 많은 테스트가 필요하다. Facebook용 Web2.0 애플리케이션을 구현한다면, 테스트를 적게 해도 된다. 필자가 알고 있는 최고의 Ruby 프로그래머들은 80% 이상의 실행코드 커버리지를 제안하고, 일부는 100% 커버리지를 목표로 하고 있다.
100% 커버리지를 달성한다고 해도, 테스트가 잘 된 것이라고 보장할 수 없다. 테스트의 유형을 고려하여 최상의 커버리지를 갖도록 해야 한다.
테스트 커버리지를 이해할 수 있는 툴이 생겼으니, 테스팅 속도에 대해 논해보자. Rails에서, 데이터베이스는 테스트의 속도를결정한다. 데이터베이스 지원 테스트 툴의 전통적인 방식은 문제가 많다. 두 개의 가장 큰 문제는 반복과 속도이다. 반복의관점에서 보면, 데이터베이스를 바꾸지 않고는 좋은 테스트 수트를 구현하기 힘들지만, 데이터베이스를 바꾸면 테스트 데이터를 바꿔야하고, 이는 테스트의 작동을 변화시킨다. 두 번째 문제는 속도이다. 데이터베이스를 바꾸는 것은 비용이 많이 든다.
속도
여러분도 알다시피, Rails 환경은 반복 문제를 해결했다. 각각의 개발자들은 테스트 데이터로 픽스쳐를 설정한다. 각 테스트케이스 전에, Rails 테스팅 프레임웍은 모델의 데이터를 지우고, 테스트 케이스에 지정한 픽스쳐(fixture)를 로딩한다.각각의 테스트 케이스는 깨끗한 상태에서 시작될 수 있다. 하지만, 테스트 케이스가 여러 개의 테스트를 갖고 있다면, 각자 서로완전히 무관해야 한다. 각각의 테스트용 픽스쳐 전체 세트를 로딩하면 너무 느려질 수 있다.
Rails는 어느 정도의 타협점을 통해서 속도 문제를 해결한다. 각각의 테스트 케이스를 실행한 후에, Rails는 모든 데이터베이스 변경 사항들을 롤백(roll back)한다. 롤백은 모든 픽스쳐 데이터를 처음부터 로딩하는 것 보다 훨씬 빠르다. 데이터베이스 액세스 비용을 무시할 수는 없다. 롤백을사용할지라도, 데이터베이스 기반 테스팅은 느리다. 테스트가 너무 느리면, 개발자들은 테스트를 실행할 수 없게 된다. 테스트를실행하지 못하면 쓸모가 없다. Rails가 반복 문제를 해결했지만, 속도 문제는 완전히 해결할 수 없다.
한 가지 대안은 테스트에 인 메모리(in-memory) 데이터베이스를 사용하는 것이다. 일반적으로, SQLite은 MySQL보다 훨씬 빠르다. 단점은 실행 시스템과 같은 플랫폼에서 테스트를 할 수 없다는 점이다.
데이터베이스 기반 테스팅에 ActiveRecord를 사용한다면, 데이터베이스 지원 테스팅으로 모든 단위 테스트를 수행할 수 있을것이다. 하지만 느린 속도를 감수해야 한다. 데이터베이스 지원 모델을 사용하여 기능 테스트를 해야만 하는 법칙은 없다. 많은Rails 개발자들은 스텁이나 Mock을 사용하여 데이터베이스를 활용하면서, 빠른 기능 테스트를 수행하고 있다.
Mocha와 FlexMock에서 Mock과 스텁 실행하기
스텁은 실제 구현을 더 간단한 구현으로 대체하는 기술이라고 설명했다. 테스트 케이스는 스텁을 사용하여 구현을 더욱 단순하고,빠르고, 예견 가능한 것으로 만든다. 예를 들어, 시스템 시계를 사용하여 같은 시간을 늘 리턴하여, 테스트가 여러분이 확인할 수있는 반복적인 결과를 낼 수 있도록 해야 한다.
Mocha 프레임웍에서는 스텁을 쉽게 수행할 수 있다. 여러분이 기대한 결과를 정의하기만 하면 된다. 다음 코드는 시스템 클래스 Date를 생성하여, 같은 날짜, 이를 테면 Ground Hog Day를 리턴한다. (Listing 6)
Listing 6. 스텁 만들기 ground_hog_day = Date.new(2007, 2,2)Date.stubs(:today).returns(ground_hog_day)assert_equal 2,Date.today.day
|
스텁이 실제의 것을단순하게 시뮬레이트 하는 것이라면, Mock은 그 이상이다. 가끔, 실제의 것을 모방하는 것으로는 충분하지 않을 때가 있다.테스트 할 때, 여러분의 코드가 API를 정확히 사용하는지를 확인해야 한다. 예를 들어, 네이티브 데이터베이스 애플리케이션이연결을 개방했는지, 쿼리를 실행하는지, 연결을 닫았는지를 확인해야 한다. 컨트롤러가 실제로 모델 객체에 save를 호출하는지를 확인해야 한다. 따라서, Mock 객체는 작동은 물론 기대하는 바도 확립해야 한다.
Rails는 적어도 세 개의 Mock 라이브러리를 갖고 있다. Mocha, FlexMock, RSpec이 바로 그것이다. 필자는Mocha에 초점을 맞추겠지만, 나머지도 고유의 장점을 갖고 있다. Mocha를 사용하면, 예상 API 호출을 명시한 다음에결과 Mocha가 리턴된다. (Listing 7)
Listing 7. Mocha Mock 라이브러리| mock_member =mock("member_67")mock_member.expects(:valid?).returns(true)mock_member.expects(:save).returns(true)mock_member.expects(:valid_captcha=).with(true)mock_member.expects(:plaintext_password).returns('password')mock_member.expects(:id).returns(67)Member.expects(:find_by_login).with(nil).returns(mock_member)post:create, :member => {}, :nonprofit => {:id =>67}...assert_response :redirectassert_redirected_to :controller =>'nonprofits', :action => 'show', :id => mock_nonprofit_id |
Listing 7은 새로운 멤버를 만드는 테스트케이스 예제를 보여주고 있다. 컨트롤러와 Mock 사용자 간 인터랙션에 대한 기대치를 만든다. Mock 멤버를 만들고 각각의인터랙션을 개별적으로 정의한다. 그런 다음, Member 클래스를 모방하고, mock_member를 리턴하는 파인더를 모방한다.
모델 레이어와의 인터랙션이 깊게 개입되어 있지만, Mock 객체는 멤버 작동을 기능 테스트 케이스에서 완벽히 분리한다. 이렇게하면 두 가지의 효과를 볼 수 있다. 신용 카드 체크아웃과 자멸(self-destruct) 스위치 같은 특정 API를 사용하는것은 현실적이지 않다. 시간 또는 메모리 기반 서비스 같은 기타 API들은 충분하지 않다. Mock 객체 프레임웍을 사용하여이들을 시뮬레이트 해야 할 것이다.
데이터베이스 지원 모델을 시뮬레이트 해야 할지 여부를 결정하는 것도 흥미롭다.한 가지 장점은 속도이다. 이 테스트 케이스는 데이터베이스를 전혀 히트하지 않는다. 또 다른 장점은 독립성이다. 테스트를 받고있는 코드를 컨트롤러 레이어와 완전히 분리한다. 하지만, 몇 가지 단점도 있다. 테스트 케이스가 상당히 복잡해진다. 또한, 모델객체와 이와 관련한 테스트 케이스를 수정해야 하기 때문에, 모델의 작동을 수정 할 때마다 변화의 폭이 심하다. 테스트 케이스가중요한 것을 놓치기도 한다. 하나의 밸리데이션을 추가하여 중요한 시나리오가 중단될 수도 있지만, 전혀 알려지지 않는다. 이와같은 이유로, ChangingThePresent의 경우 모델 객체 클래스들을 모사하지 않는다. Mock을 서드 파티에 대한 웹서비스나 네트워크 서비스 같은 외부 인터페이스로 제한한다.
Ruby 커뮤니티는 Mock의 전략적 방향에 상당히 의존적이다. 필자는 데이터베이스 지원 테스팅을 고수하기로 결정했다. 우리는 두 가지 모두를 시도했다. 코드 베이스에 더욱 잘 맞기 때문에 이러한 기술들을 사용하기로 한 것이다.
Continuous integration
가장 중요한 향상은 continuous integration (CI)이다. 우리는 Ruby 버전의 Cruise Control을실행한다. CI 서버는 깨끗한 구현을 검사하고 새로운 변화가 생길 때마다 처음부터 테스트 케이스를 실행한다. 서버는 변경사항으로 구현이 중지될 때마다 개발자에게 공지한다. 이 서버로는 체크인 하기 전에 몇 가지 대표적인 테스트를 실행할 수 있다.Member에서 몇 줄을 수정한 다음, unit/member_test.rb와 functional/members_controller_test.rb를 실행한다. 15초가 지난 다음, 체크인 할 수 있고, Cruise Control이 잘못된 부분을 알려준다.
결론
몇 년 전에는, 자동화된 테스팅이 과연 좋은 것인지에 대한 논의가 있었다. 이제, 이러한 논의는 더 재미있어졌다.
- 모든 테스트가 데이터베이스 기반이어야 하는가, 아니면 Mock과 스텁을 사용하여 기능 테스트를 고립시켜야 하는가?
- 100% 커버리지가 현실적으로 가능한가?
- 인-메모리 테스트의 속도가 부가적인 위험 요소들을 상쇄하는가?
여러분에게 해줄 수 있는 말은 여러분의 작업 특성과 고객에 맞게 기술을 선택하라는 것이다. 일부 전문가의 말을 무조건 맹신하지말라. 향후 몇 년 안에 이 모든 것에 대한 대답을 얻지 못할 수도 있다. 새로운 기술이 탄생하고, 기존 기술이 밀려난다.문서상으로 기록된 것이 실제의 Rails를 정확히 반영하는 것은 아니다.
참고자료
교육제품 및 기술 얻기토론