Unit Test vs Functional TestそしてClean Code


Agile2008でもらったゴムバンドを未だに手首につけている。確かBob Martinだったと思うが、テスト駆動開発と「Clean Code」の関係について熱く語っていた年だ。

  • メソッドは短く。
  • メソッドが実現することは一つ。
  • あるメソッドのテストに色々と条件を設定しているのなら、それはClean Codeではない。

だが我々はその基本を簡単に忘れてしまう。色々とテストのための道具が揃ってきたせいもあろう。基本を忘れて一つのメソッドに色々と詰め込みすぎるとテストが大変になる。Mockがあっても、だ。Fixture使うのはさらに大変だし、Seleniumとかで入力から何から条件を与えるのはもっと面倒。そしておそらく抜けが発生する。

最近、内職でPython使ったアプリを組んでいるのだが、今回は上記「基本」を徹底するようにしている。例えばこんなコードがある。

def nearby(request):
    "/nearby/"
    access_token = request.COOKIES.get('access_token', None)
    if not access_token:
        return HttpResponse("You need an access token COOKIES!")
    token = oauth.OAuthToken.from_string(access_token)   
    
    location = get_location(token)
    if location['stat'] != 'ok':
        return HttpResponse("Something went wrong: <pre>" + json)
    try:
        best_location = location['user']['location_hierarchy'][0]
    except IndexError:
        return HttpResponse(
          "Fire Eagle is not currently sharing your location with wikinear.com"
        )
    
    geometry = best_location['geometry']
    if geometry['type'] == 'Polygon':
        bbox = geometry['bbox']
        lat, lon = lat_lon_from_bbox(bbox)
        is_exact = False
    elif geometry['type'] == 'Point':
        lon, lat = geometry['coordinates']
        is_exact = True
    else:
        return HttpResponse("Location was not Point or Polygon: <pre>" + json)
    nearby_pages = get_nearby_pages(lat, lon)
    return render_to_response('nearby.html', {
        'lat': lat,
        'lon': lon,
        'location': location,
        'best_location': best_location,
        'nearby_pages': nearby_pages,
        'is_exact': is_exact,
    })

これをテストするにはどうすればいいだろうか? Djangoのコントローラであるから、webtestなどのフレームワークを使うという手もある。だが、このメソッドの中にif分岐や例外捕獲は何箇所あるだろう?これら全てを試すために必要な設定はどうすればいいだろう?仮にテストを網羅したとしても、nearby()に何かの変更が発生した場合、テストコードや条件設定には多くの見直しが発生する。これこそ「長くて」「いろいろな役目を一つのメソッドに詰め込んだ」弊害なわけである。

まずは以下のように変更してみる。

def _get_access_token(request):
    return request.COOKIES.get('access_token', None)

def _http_response(message):
    return HttpResponse(message)

def nearby(request):
    "/nearby/"
    access_token = _get_access_token(request)
    if not access_token:
        return _http_response("You need an access token COOKIES!")
    return _nearby_with_valid_token(request, access_token)

def _nearby_with_valid_token(request, access_token):
    token = oauth.OAuthToken.from_string(access_token)   
    
    location = get_location(token)
    if location['stat'] != 'ok':
        return HttpResponse("Something went wrong: <pre>" + json)
    try:
        best_location = location['user']['location_hierarchy'][0]
    except IndexError:
        return HttpResponse(
          "Fire Eagle is not currently sharing your location with wikinear.com"
        )
    
    geometry = best_location['geometry']
    if geometry['type'] == 'Polygon':
        bbox = geometry['bbox']
        lat, lon = lat_lon_from_bbox(bbox)
        is_exact = False
    elif geometry['type'] == 'Point':
        lon, lat = geometry['coordinates']
        is_exact = True
    else:
        return HttpResponse("Location was not Point or Polygon: <pre>" + json)
    nearby_pages = get_nearby_pages(lat, lon)
    return render_to_response('nearby.html', {
        'lat': lat,
        'lon': lon,
        'location': location,
        'best_location': best_location,
        'nearby_pages': nearby_pages,
        'is_exact': is_exact,
    })

access_tokenが空かどうか試し、空ならHttpResponseでエラー表示。そうでなければ、_nearby_with_valid_tokenに処理を任せる。つまり、nearbyは「access_tokenが存在するかどうか確認するだけのメソッド」となる。Unit Testもここに焦点をおけばよい。テストコードはこんな感じになる。

class StatTest(unittest.TestCase):
	@mock.patch('views._get_access_token')
	@mock.patch('views._http_response')
	@mock.patch('views._nearby_with_valid_token')
	def test_nearby(self, nearby_mock, response_mock, token_mock):
		response_mock.return_value = 100
		nearby_mock.return_value = 200

		token_mock.return_value = None
		self.assertEqual(100, views.nearby(None))

		token_mock.return_value = True
		self.assertEqual(200, views.nearby(None))

Djangoフレームワークは使ってない。「それではCookieを見てないじゃないか」と考える人がいるかもしれない。だけど、今焦点をあてているのは「access_tokenが空っぽかそうじゃないか」というif文だけなのでCookieはMockを使えば良いのである。同様に、nearbyがHttpResponseで何を返すとか、そのcontentsにどんな文字列が含まれているか、なんてことはどうでもよい。なので、戻り値もmockで代用し、数字の100と200で代用。Unit Testだからこれで良いのだ。

以下、これを繰り返していく。今度は_nearby_with_valid_tokenを「一つの役目を負った」メソッドにリファクタリングするのである。

def _get_token(access_token):
    return oauth.OAuthToken.from_string(access_token)   

def _nearby_with_valid_token(request, access_token):
    token = _get_token(access_token)
    location = get_location(token)
    if location['stat'] != 'ok':
        return _http_response("Something went wrong: <pre>" + json)
    return _nearby_with_location(request, location)

def _nearby_with_location(request, location):
    try:
        best_location = location['user']['location_hierarchy'][0]
    except IndexError:
        return HttpResponse(
          "Fire Eagle is not currently sharing your location with wikinear.com"
        )
    
    geometry = best_location['geometry']
    if geometry['type'] == 'Polygon':
        bbox = geometry['bbox']
        lat, lon = lat_lon_from_bbox(bbox)
        is_exact = False
    elif geometry['type'] == 'Point':
        lon, lat = geometry['coordinates']
        is_exact = True
    else:
        return HttpResponse("Location was not Point or Polygon: <pre>" + json)
    nearby_pages = get_nearby_pages(lat, lon)
    return render_to_response('nearby.html', {
        'lat': lat,
        'lon': lon,
        'location': location,
        'best_location': best_location,
        'nearby_pages': nearby_pages,
        'is_exact': is_exact,
    })

これをひたすら繰り返して行き、これ以上細かく分割できないというところまで来たら終了。

実際に試せばわかるが、Djangoフレームワークもoauthも(このコードにはないけど)データベースも全部Mockにできるので、Unit Testが非常に早くなる。また、「一つのメソッドにいろいろ詰め込まない」ことでUnit Testの網羅率は高まる。

もちろん、どこかの時点でコントローラを巻き込んだFunctional Testさらには受け入れレベルのUATは必要になる。ただしそれは基本的な(いわゆる正常系と呼ばれることが多い)場合を試せばよかろう。大事なことはFunctional TestやAcceptance Testでコードの網羅率を考えなくて済むようにすることである。そのためにはUnit Testでの網羅率を上げることが第一であり、小さく単純なメソッドを組み合わせることで実現できる。

おまけ

もちろん、こんなプロジェクトに巻き込まれたら上記手段は使えない。合掌。