SpecFlowとWatiNによるASP.NET MVCの振る舞い駆動テスト実践

振る舞い駆動開発(BDD)の基本概念

テストファーストのアプローチが標準化する中、検証の焦点は内部構造の検証からシステム全体の振る舞いへ移行しています。BDD(Behavior-Driven Development)は、技術的な実装詳細ではなく「ユーザーがシステムを通じて達成したい業務上の目的」を、実行可能な仕様として定義する開発手法です。TDDがクラスやメソッド単位の結合性を担保するのに対し、BDDは機能レベルの受け入れテストを起点とし、要件を満たすために必要な内部実装へ段階的に展開するOutside-Inな設計プロセスを促進します。

SpecFlowとGherkin構文の仕組み

BDDでは、技術者と非技術者の双方が理解できる自然言語に近い形式でシナリオを記述します。.NETエコシステムにおける代表的なツールであるSpecFlowは、Gherkin形式で記述されたFeatureファイルを解析し、自動的にテスト実行コードを生成します。構文は主に3つの要素で構成されます。

  • Given:テスト実行前のシステム状態や前提条件
  • When:ユーザーが実行する操作またはシステムに送るイベント
  • Then:操作後に期待される観測可能な結果

MSTest連携の設定

Visual Studio上でSpecFlowを動作させる際、デフォルトのテストランナーを変更する場合は、テストプロジェクトに設定ファイルを追加し、プロバイダを明示します。

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="specFlow" type="TechTalk.SpecFlow.Configuration.ConfigurationSectionHandler, TechTalk.SpecFlow" />
  </configSections>
  <specFlow>
    <unitTestProvider name="MsTest" />
  </specFlow>
</configuration>

.feature拡張子のファイルを作成すると、SpecFlowは保存時に自動的にコードビハインド(.feature.cs)を生成します。各シナリオは独立したテストメソッドとしてコンパイルされ、記述したステップ文字列は正規表現を用いてステップ定義クラス内のメソッドへバインドされます。

WatiNによるブラウザ自動操作の統合

エンドツーエンドの検証を実現するには、実際のUI操作を自動化するライブラリが不可欠です。WatiNはDOM要素の操作とブラウザ制御を提供するオープンソースツールであり、SpecFlowと組み合わせることでGUIレベルのテストシナリオを自動化できます。複数のステップ定義間で同一のブラウザインスタンスを共有するため、ScenarioContextを介してセッション状態を管理するヘルパーを用意します。

public static class BrowserSession
{
    private const string ContextKey = "active_browser_session";

    public static IE Active
    {
        get
        {
            var scope = ScenarioContext.Current;
            if (!scope.ContainsKey(ContextKey))
            {
                scope[ContextKey] = new IE();
            }
            return scope[ContextKey] as IE;
        }
    }

    public static void Close()
    {
        var scope = ScenarioContext.Current;
        if (scope.ContainsKey(ContextKey))
        {
            var engine = scope[ContextKey] as IE;
            engine?.Close();
            engine?.Dispose();
            scope.Remove(ContextKey);
        }
    }
}

ステップ定義の実装例

Featureファイルの各文節は、属性によるアノテーションと正規表現でマッピングされます。以下は、ユーザー登録フローを検証するための実装パターンです。変数名、ロジック構成、アサーション手法はリファクタリングされていますが、SpecFlow/WatiNの動作仕様に準拠しています。

[Given(@"サイト管理者として認証済みである")]
public void PrepareAdministratorContext()
{
    var targetUrl = "http://localhost:24613/Account/Login";
    BrowserSession.Active.GoTo(targetUrl);
    
    BrowserSession.Active.TextField(Find.ByName("loginId")).TypeText("admin");
    BrowserSession.Active.TextField(Find.ByName("loginPwd")).TypeText("SecurePass!23");
    BrowserSession.Active.Button(Find.ByValue("ログイン実行")).Click();

    Assert.IsTrue(BrowserSession.Active.Link(Find.ByText("ログアウト")).Exists, "認証状態の確認に失敗しました。");
}

[When(@"\"(.*)\" というリンクを選択する")]
public void NavigateViaLink(string targetLabel)
{
    var navigationTarget = BrowserSession.Active.Link(Find.ByText(targetLabel));
    if (!navigationTarget.Exists)
    {
        Assert.Fail($"指定された '{targetLabel}' リンクがDOM上に存在しません。");
    }
    navigationTarget.Click();
}

[When(@"以下の情報を入力する")]
public void PopulateRegistrationForm(Table formData)
{
    foreach (var entry in formData.Rows)
    {
        var inputKey = entry["入力フィールド名"];
        var inputValue = entry["入力データ"];
        var fieldElement = BrowserSession.Active.TextField(Find.ByName(inputKey));

        if (!fieldElement.Exists)
        {
            Assert.Fail($"フォーム内に '{inputKey}' の入力欄が見つかりません。");
        }
        fieldElement.TypeText(inputValue);
    }
}

[When(@"\"(.*)\" のボタンを押下する")]
public void SubmitRegistrationForm(string buttonCaption)
{
    var triggerButton = BrowserSession.Active.Button(Find.ByValue(buttonCaption));
    Assert.IsTrue(triggerButton.Exists, $"操作対象のボタン '{buttonCaption}' が検出されませんでした。");
    triggerButton.Click();
}

[Then(@"画面に以下の詳細情報が表示されること")]
public void VerifyScreenOutput(Table expectedOutput)
{
    foreach (var row in expectedOutput.Rows)
    {
        var expectedContent = row["期待表示値"];
        bool isContentPresent = BrowserSession.Active.ContainsText(expectedContent);
        Assert.IsTrue(isContentPresent, $"期待値 '{expectedContent}' がレンダリング結果に含まれていません。");
    }
}

この時点でテストを実行すると、UI部品の欠落またはバックエンドの未実装により失敗します。BDDではこの「赤」状態を起点に、必要な内部実装へ向けて開発を進めます。

外部仕様から内部実装への展開

受け入れテストのフローが定義されたら、次にユニットテストを用いた内側の検証ループへ移行します。Featureシナリオを満たすために必要なコントローラアクションとビューを、テストファーストの原則に従って構築します。

[TestMethod]
public void VerifyNewRegistrationViewInitialization()
{
    var controller = new UserManagementController();
    var result = controller.OpenRegistrationForm() as ViewResult;

    Assert.IsNotNull(result, "ActionResultが初期化されていません。");
    Assert.AreEqual("RegistrationView", result.ViewName, "返却されるビュー名が一致しません。");
    Assert.IsInstanceOfType(result.ViewData.Model, typeof(UserProfile), "モデルオブジェクトの型が期待と異なります。");
}

コンパイルエラーを解消するためにクラスとメソッドのスケルトンを作成し、テストが通る最小限のロジックを実装します。

public ActionResult OpenRegistrationForm()
{
    var initialModel = new UserProfile();
    return View("RegistrationView", initialModel);
}

ユニットテストが「緑」になれば、再度Feature全体のテストを実行します。次はフォーム送信の受け口やデータ永続化処理の実装不足で失敗するため、同様のテストファーストサイクルを繰り返します。

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult OpenRegistrationForm(UserProfile submittedData)
{
    if (!ModelState.IsValid)
    {
        return View("RegistrationView", submittedData);
    }

    _dataRepository.Insert(submittedData);
    return RedirectToAction("ProfileDetails", new { uid = submittedData.Id });
}

このように、BDDの外側ループで機能の到達目標を定め、TDDの内側ループで実装の正確性を保証するプロセスを繰り返すことで、回帰テストに強い疎結合なアーキテクチャが形成されます。

テストコードの整理とリファクタリング

シナリオ数が増加すると、ステップ定義ファイルが冗長化しがちです。認証フローやナビゲーション操作など、複数のFeatureで頻繁に出現するステップは、役割ごとに専用クラス(例:AuthenticationSteps.cs)へ切り出し、保守性を向上させる必要があります。また、Featureファイル自体もドメイン言語に焦点を当て、技術的なセレクタや実装詳細を排除することで、仕様の可読性を維持できます。テストコードの実装段階でも、DRY原則に従ったメソッド抽出やヘルパークラスの設計を継続的に適用することで、長期的なテストメンテナンスコストを抑制します。

タグ: SpecFlow WatiN ASP.NET MVC MSTest Gherkin

10月12日 09:43 投稿