読者です 読者をやめる 読者になる 読者になる

mixi engineer blog

ミクシィ・グループで、実際に開発に携わっているエンジニア達が執筆している公式ブログです。様々なサービスの開発や運用を行っていく際に得た技術情報から採用情報まで、有益な情報を幅広く取り扱っています。

GHUnitで単体テストをしてみよう

iOS Objective-C

初めまして。プログラマのショウといいます。
現在、mixiの公式iPhoneアプリを担当しています。

今回は、iPhoneアプリ開発におけるGHUnitを用いた単体テストについて紹介したいと思います。

★ テストとは

本題に入る前に少しだけ、テストという概念について整理してみましょう。

ソフトウェアを開発する上での「テスト」という言葉は、「コンピュータのプログラムを実行し、正しく動作するかを確認する作業のこと」を指します。

そしてこの「正しく動作するかを確認する方法」として主に以下の2通りがあります。
・ ホワイトボックステスト
・ ブラックボックステスト

ホワイトボックステストとは、「命令網羅」「分岐網羅」「条件網羅」などの方式を用いて、プログラム内部の動作がプログラマの意図通りとなっているかを確認するものとなります。

これに対してブラックボックステストとは、プログラム内部に関係なく、外部から見て仕様通りの機能を持っているかを確認するものとなります。

★ 単体テストとは

では、今回のテーマである単体テストとは何でしょうか。

単体テスト(Unit Testing)とは、プログラム内部の個々のモジュール(部品)のみを対象としたテストを指します。

対象のモジュールが仕様書で要求された機能や性能を満たしているかどうかをテストするものであり、主にホワイトボックステストの手法が用いられます。

単体テストには、各プログラミング言語に用意されたxUnitフレームワークや、iPhoneアプリであればOCUnitフレームワークなどの単体テスト用ツールを用いるのが一般的となっています。

これらツールでは、個々のモジュールに対応した「テストコード」を書いて実行させることで、モジュールレベルでの不具合が存在しないかを確認することができます。

また、一度書いたテストコードは何度も実行できるため、定期的に全てのテストコードを実行させることでソフトウェアの品質を担保していく、というのも単体テストの目的のひとつとなります。

★ 単体テストの必要性

では「単体テストはやった方がよいのか?」を考えた時、その答えはどうでしょうか。

私はこれまで多くのエンジニアとこのような会話をしてきましたが、単体テストをやるべきという人の意見としては、
・ 「納品物に対する品質が担保できる」
・ 「メンテナンスしていく中でのデグレを防げる」
・ 「テスト前提のコードはプログラム設計が綺麗」

などがあり、逆に単体テストはやりたくないという人の意見としては、
・ 「テストコードを書く時間がない」
・ 「テストコードの保守をしていかければならない」
・ 「どこまでをどれだけやるかを明確にするのが難しい」
・ 「単体テストという項目を工数見積もりに載せにくい」
といった事を聞いたことがあります。

品質面でのメリットと引き換えに工数面や精神面でのデメリットもあり、それらがネックとなって単体テストに抵抗を感じるエンジニアも存在します。

故に単体テストの必要性については、メリット・デメリットを考え、それぞれの開発プロジェクトの状況下で必要か否かを検討するしかないのかなと、個人的に思います。

★ iPhoneアプリで単体テストをしてみよう

前置きが長くなってしまいましたが、本題に入っていきたいと思います。

単体テストの必要性などを整理してみましたが、mixi規模のサービスとなれば品質は最重要であり、iPhoneアプリとしても単体テストのメリットを享受しないわけにはいきません。

mixiの公式iPhoneアプリは単体テストにGHUnitと呼ばれるテスト用フレームワークを採用してします。

さらに、ソースコードをリポジトリサーバにコミットすると、Jenkinsが単体テストを自動実行し、テストが通った場合に自動ビルドが行われる、という仕組みを導入しています。
※この仕組みについては、次回以降の記事で紹介したい思います。

それではこのGHUnitの使い方について、簡単に紹介していきたいと思います。

★ GHUnitとは

GHUnitはMax OS X / iOS用の単体テストフレームワークであり、以下の特徴を持っています。

・ 非同期通信のテストが容易にできる
・ iPhone実機・シミュレータで動作確認ができる(実機でしか発生しないバグをキャッチできる)

XcodeにはデフォルトでOCUnitと呼ばれる単体テストフレームワークが付属していますが、GHUnitではOCUnitで出来ることに加え、上記の特徴を持っているのが魅力となっています。

★ GHUnitのダウンロード

以下URLから「GHUnitIOS-x.x.xx.zip」をダウンロードします。
https://github.com/gabriel/gh-unit/downloads

ダウンロードしたものを解凍したら、「GHUnitIOS.framework」というフォルダができます。

まずはこれをXcodeプロジェクトに組み込むまでのプロセスを解説していきます。

★ Xcodeプロジェクトの作成とGHUnitフレームワークの追加

Xcodeを起動し、新規プロジェクトを作成します。ここでは簡単なサンプルのため「Single View Application」を選択します。



プロジェクト名は「Sample」とし、ここでは各チェックボックスは全てOFFとします。

Xcodeプロジェクトが出来上がったら、GHUnit用のTargetをひとつ追加します。下部にある「Add Target」をクリックします。

デフォルトのソースコードは全て削除するため、「Empty Application」を選択します。

プロジェクト名は「Tests」とし、ここでも各チェックボックスは全てOFFとします。

新しく「Tests」グループがSampleプロジェクト内に作成されます。ここからTests-Info.plistとTests-Prefix.pch以外は不要となるため削除します。


(不要なファイルを削除)

次に、その下のグループの「Frameworks」を右クリックし、「Add Files to "Sample"」を選択、さきほどダウンロードした「GHUnitIOS.framework」を選択します。

「Add to targets」のTestsのチェックボックスをONにします。

「Frameworks」にGHUnitが追加され、使用可能となりました。

ここまできたら後ひと息です。最後に、Testsターゲットの「Build Settings」を選択し、「Other Linker Flags」の項目に、「-ObjC」を追加します。

次にTestsグループにmainメソッドのクラスファイルを追加します。ここでは「SampleTestMain」という名前で、以下の内容でソースコードを追加します。

SampleTestMain.m

#import 
 
void exceptionHandler(NSException *exception) {
    NSLog(@"%@\n%@", [exception reason], GHUStackTraceFromException(exception));
}
 
int main(int argc, char *argv[]) {
    NSSetUncaughtExceptionHandler(&exceptionHandler);
 
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
 
    int retVal = 0;
    if (getenv("GHUNIT_CLI")) {
        retVal = [GHTestRunner run];
    } else {
        retVal = UIApplicationMain(argc, argv, nil, @"GHUnitIPhoneAppDelegate");
    }
 
    [pool release];
    return retVal;
}

準備が整いました。ビルドターゲットに「Tests」、実行環境にiPhone Simulatorを選択し、起動してみましょう。

以下のようにGHUnitのアプリが起動すれば、組み込み完了です。

★ テストコードの追加と実行

環境が整ったため、実際にGHUnitを使って単体テストをしてみましょう。

Sampleグループにテスト対象のCalcというクラスファイルを追加し、「int型の引数同士を除算して切り上げた値を返却する」divideRoundUpメソッドを記述します。

Calc.h

#import 
 
@interface Calc : NSObject
 
+ (NSInteger)divideRoundUp:(NSInteger)numerator denominator:(NSInteger)denominator;
 
@end

中身を実装します。

Calc.m

#import "Calc.h"
 
@implementation Calc
 
+ (NSInteger)divideRoundUp:(NSInteger)numerator denominator:(NSInteger)denominator;
{
    CGFloat value = (CGFloat)numerator / (CGFloat)denominator;
    return ceil(value);
}
 
@end

これでテスト対象となる機能ができました。次にテストを実行するクラスを作成します。

TestsグループにCalcTestというクラスファイルを追加します。

CalcTest.m

#import 
#import "Calc.h"
 
@interface CalcTest : GHTestCase {}
@end
 
@implementation CalcTest
 
// デフォルトはNOですが、UIのテストやメインスレッドに依存するテストを行う場合はYESにします。
- (BOOL)shouldRunOnMainThread {
    return NO;
}
 
// 本クラスが実行される前に呼び出されます。
- (void)setUpClass {
}
 
// 本クラスが終了された後に呼び出されます。
- (void)tearDownClass {
}
 
// 本クラスの各メソッドが実行される前に呼び出されます。
- (void)setUp {
}
 
// 本クラスの各メソッドが終了された後に呼び出されます。
- (void)tearDown {
}
 
// 「test~」というメソッド名にすることでテスト対象一覧に出力されます。
- (void)testDivideRoundUp
{
    NSInteger num = [Calc divideRoundUp:100 denominator:3];
    GHAssertEquals(num, 34, @"test");
}
 
@end

さきほど追加したCalc.hをインポートし、GHTestCaseクラスを継承しています。

testDivideRoundUpメソッドがCalcクラスのdivideRoundUpメソッドに対応したテストメソッドとなります。

GHUnitにテストメソッドであることを伝えるため、テストメソッドの接頭語は「test〜」とする必要があります。

この状態でiPhoneシミュレータを起動し、ナビゲーションバーにあるRunボタンを押して、テストを実行してみましょう。

問題なくテストが通ったでしょうか。

testDivideRoundUpメソッドでは、divideRoundUpメソッド で100÷3を切り上げた数値を取得し、その答えが34となるかどうかをGHUnitで用意されたGHAssertEquals関数を使って確認しています。

NSInteger num = [Calc divideRoundUp:100 denominator:3];
GHAssertEquals(num, 34, @"test");

それでは、CalcクラスのdivideRoundUpメソッドの実装が、「切り上げ」ではなく「切り下げ」の値を取得してしまうよう、不具合を仕込んでみます。

Calc.mのdivideRoundUpメソッドの中のceil関数を、切り下げを行うfloor関数に変更します。

Calc.m

+ (NSInteger)divideRoundUp:(NSInteger)numerator denominator:(NSInteger)denominator;
{
    CGFloat value = (CGFloat)numerator / (CGFloat)denominator;
    return floor(value); // ceilからfloorに変更しました
}

それでは再度iPhoneシミュレータを起動し、テストを実行してみます。

Line:35
Reason:'33' should be equal to '34'. test

「35行目の値が'34'にならなければいけないのに'33'でした」と怒られ、想定と異なる値が返ってくる不具合を発見することができました。

以上が、GHUnitを使った単体テストの流れとなります。

★ まとめ

いかがでしたでしょうか。

今回は私たちがmixi公式iPhoneアプリで採用しているGHUnitの導入と使い方について、簡単に紹介しました。

GHUnitでは非同期通信のテストなど、秘めたるものがまだまだあります。本当はそちらも紹介していきたいのですが、そこまでの需要があるかどうかは少し謎なため、はてブのブックマーク数でも見て決めようと思います。

それでは、また次回お会いしましょう!

※本記事では執筆時点で最新のXcode 4.3.2を使用しています。