JMeterでテスト停止時のログアウトを保証する

## 問題

[JMeter](http://jmeter.apache.org/) で

1. ログイン
1. 対象ページ操作
1. ログアウト

のようなシナリオを、[Stepping Thread Group](http://jmeter-plugins.org/wiki/SteppingThreadGroup/)などを使用して一定時間実行するとします。

このとき困るのが、「一定時間」が経過してテストが終了するときや、終了ボタンを押して手動でテストを止めたときなどに、ログアウトまで進まずに途中で終わってしまうスレッドがあることです。何度も実行しているとセッション情報が無駄に溜まっていってしまうし、ログイン数を制限しているようなアプリケーションの場合は正常にテストができなくなることもあります。

なので、テスト期間が終わってシナリオが途中で終了されたり、JMeterの中止ボタンを押してテストを打ち切ったりしても、必ずログアウト操作は行われるようにしたいところです。さて、どうやって実現すれば良いか？

## 対応策

スレッドグループ毎にJavaの `finally` ブロックのような「スレッド終了時に必ず実行されるコントローラー」を作れれば良いのですが、JMeterにはそのような機能はありません。「終了時に必ず実行される動作の定義」が出来そうなのは、[TearDown Thread Group](http://jmeter.apache.org/usermanual/component_reference.html#tearDown_Thread_Group)という、テストの終了前に実行される特別なスレッドグループの定義だけのようです。しょうがないのでこれを使ってみます。

とりあえず考えられる構成は、以下のようになりますが、

1. HTTP Cookie Manager
1. Stepping Thread Group
   1. HTTP Sampler（ログイン）
   1. HTTP Sampler（対象ページ操作）
   1. HTTP Sampler（ログアウト）
1. TearDown Thread Group
   1. HTTP Sampler（ログアウト）

TearDown Thread Groupはメインシナリオ（Stepping Thread Group）とは別のスレッドグループなので、単純にログアウトを行ってもメインシナリオでログインした際のセッションは使用されず、正しいログアウトは行えません。

TearDown Thread Groupの中でStepping Thread Groupのスレッドが持っていたセッションを使うには、Stepping Thread Groupのセッション情報（つまり Cookie）をTearDown Thread Group側に渡す仕組みが必要となります。

このため、以下のような構成を考えます。

1. HTTP Cookie Manager
1. Stepping Thread Group
    1. HTTP Sampler（ログイン）
        * **PostProcessor**（発行されたCookieをリストに追加）
    1. HTTP Sampler（対象ページ操作）
    1. HTTP Sampler（ログアウト）
        * **PostProcessor**（ログアウト済みのCookieをリストから削除）
1. **TearDown Thread Group**
    1. **While Controller**（ログアウトが済んでいないCookieがある間ループ）
        1. **HTTP Sampler**（ログアウト）
            * **PreProcessor**（Cookieのリストから1つCookieを取り出してセット）

太字が元のテスト計画から増えた部分です。ログイン時に発行されたCookieを「Cookieのリスト」に保存しておき、TearDown Thread Groupで「リスト」の中のCookieに対してログアウト処理を行っています。

以下、一つ一つの要素を検討します。

## PostProcessor（発行されたCookieをリストに追加）

ログイン後の[PostProcessor](http://jmeter.apache.org/usermanual/component_reference.html#postprocessors)で、ログイン時に発行されたCookieを「リスト」に保存する部分です。この保存先は、以下の要件を満たす必要があります。

* 保存したCookieの情報は後でTearDown Thread Groupから参照するため、スレッドをまたがって参照可能であること
* 複数スレッドでログインが行われるので、複数のCookieを別々に保存でき、スレッドセーフであること

最初の要件については、Jmeterのサイトに「[スレッドをまたがって共有したい値の格納先にはJmeterのプロパティを使える](http://jmeter.apache.org/usermanual/best-practices.html#bsh_variables)」と記載されているので、とりあえずプロパティを使うことにします。

二つ目の要件は、格納先としてConcurrentHashMapを使用し、キーはスレッド名、値はスレッドが保持しているCookie全てとしておけば良さそうです（スレッド名は`<ThreadGroup名> <テスト計画でのThreadGroupのインデックス>-<ThreadGroup毎のスレッド番号>`となっていて使い回される可能性がありますが、使い回される場合はログアウトまで終わって次のループに入っているということなので、保持しておく必要のあるCookieはスレッド名ごとに1つしかないはずです）。

やり方が決まったので、あとは[BeanShell PostProcessor](http://jmeter.apache.org/usermanual/component_reference.html#BeanShell_PostProcessor)を使用して[Cookie](http://jmeter.apache.org/api/org/apache/jmeter/protocol/http/control/Cookie.html)を格納するスクリプトを記述します。

```java:発行されたCookieを格納するBeanShell_PostProcessor
import java.util.concurrent.ConcurrentHashMap;
import org.apache.jmeter.protocol.http.control.CookieManager;

// HttpSampler 決め打ち
CookieManager manager = ctx.getCurrentSampler().getCookieManager();
String threadName = ctx.getThread().getThreadName();

// プロパティのキーは何でも良い（jmeter.properties にあるキーとかぶらなければ）
ConcurrentHashMap cookiesMap = props.get("com.example.SavedCookies");
cookiesMap.put(threadName, manager.getCookies());

log.info("Logged in: thread name=" + threadName);
```

上のスクリプトでは、JMeterのプロパティ `props` に既にConcurrentHashMapが入っていることを仮定しています。「無かったら作成して格納」としてしまうと複数スレッドで同時に動作した場合にCookieが消えてしまう懸念があり、防止のために同期化もしたくないので、TearDown Thread Groupと対になる[SetUp Thread Group](http://jmeter.apache.org/usermanual/component_reference.html#setUp_Thread_Group)を使用して、テストの開始時に以下のBeanShell Sampler で格納しておくことにします。このSetUp Thread Groupは、スレッド数もループ回数もデフォルトの 1 でかまいません。

```java:ConcurrentHashMapを作成しておくBeanShell_Sampler
import java.util.concurrent.ConcurrentHashMap;

props.put("com.example.SavedCookies", new ConcurrentHashMap());
```

## PostProcessor（ログアウト済みのCookieをリストから削除）

ログアウトまで普通に完了した場合は、保存していたCookie情報は不要になるため、削除しておきます。こちらもBeanShell PostProcessorを使用して、以下のように記述します。

```java:ログアウト済みのCookieを削除するBeanShell_PostProcessor
import java.util.concurrent.ConcurrentHashMap;

String threadName = ctx.getThread().getThreadName();

ConcurrentHashMap cookiesMap = props.get("com.example.SavedCookies");
cookiesMap.remove(threadName);

log.info("Logged out: thread name=" + threadName);
```

## TearDown Thread Group

このスレッドグループにもスレッド数やループ回数が設定できますが、スレッド数もループ回数もデフォルトの 1 とします。実行は1回のみで、内部で以下のWhile Controllerを使用して必要な回数だけログアウト用Samplerを呼び出すことになります。

## While Controller（ログアウトが済んでいないCookieがある間ループ）

TearDown Thread Groupに来た時点でConcurrentHashMapに入っているCookieは、ログイン時のPostProcessorで保存され、ログアウト時のPostProcessorで破棄されていないものなので、ログアウト処理が必要なセッションのCookieであるということになります。

このようなCookieが存在する間だけ、[While Controller](http://jmeter.apache.org/usermanual/component_reference.html#While_Controller) でログアウト用のSamplerを動作させたいのですが、While Controllerの条件にはあまり凝った記述ができないため、その直前に、ログアウト処理が必要なCookieがあるかどうかを変数にセットする以下のような[BeanShell Sampler](http://jmeter.apache.org/usermanual/component_reference.html#BeanShell_Sampler)を配置しておきます。また、ここまで来たらもうプロパティにCookieを保存しておく必要は無いので、ついでにプロパティからConcurrentHashMapを削除しておきます。

```java:Cookieがあるかどうかを確認するBeanShell_Sampler
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;

// プロパティからConcurrentHashMapを削除し、扱いやすいようにしておく
ConcurrentHashMap cookiesMap = props.remove("com.example.SavedCookies");
Iterator cookiesIterator = cookiesMap.entrySet().iterator();
vars.putObject("cookiesIterator", cookiesIterator);

// 変数 hasMoreCookies は、未処理の Cookie が無いなら "false" になる
vars.put("hasMoreCookies", String.valueOf(cookiesIterator.hasNext()));
```

こうしておいて、While Controllerの条件には `${hasMoreCookies}` を指定します。

## PreProcessor（Cookieのリストから1つ取り出してセット）

この[PreProcessor](http://jmeter.apache.org/usermanual/component_reference.html#preprocessors)では、保存していたCookieをログアウト用のSamplerが使用できるよう、[CookieManager](http://jmeter.apache.org/api/org/apache/jmeter/protocol/http/control/CookieManager.html)にセットします。以下のような[BeanShell PreProcessor](http://jmeter.apache.org/usermanual/component_reference.html#BeanShell_PreProcessor)を記述すれば良いです。

```java:Cookieのリストから1つ取り出してセットするBeanShell_PreProcessor
import java.util.Iterator;
import java.util.Map;
import org.apache.jmeter.protocol.http.control.CookieManager;
import org.apache.jmeter.testelement.property.CollectionProperty;
import org.apache.jmeter.testelement.property.PropertyIterator;

// 最初に CookieManager#getCookies() で取得した Cookie
Iterator cookiesIterator = vars.getObject("cookiesIterator");
Map.Entry cookies = cookiesIterator.next();
log.info("Cleaning up: thread name=" + cookies.getKey());

if (cookies.getValue() != null) {
    CookieManager manager = ctx.getCurrentSampler().getCookieManager();
    PropertyIterator it = cookies.getValue().iterator();
    while (it.hasNext()) {
        manager.add(it.next().getObjectValue());
    }
}

// 次回のループ判定のため、変数をセットしなおしておく
vars.put("hasMoreCookies", String.valueOf(cookiesIterator.hasNext()));
```

## 完成

途中でいくつか追加の要素が必要になったので、最終的には以下のようなテスト計画になります。

1. HTTP Cookie Manager
1. **SetUp Thread Group**
    1. **BeanShell Sampler**（ConcurrentHashMapの初期化）
1. Stepping Thread Group
    1. HTTP Sampler（ログイン）
        * **BeanShell PostProcessor**（発行されたCookieをConcurrentHashMapに追加）
    1. HTTP Sampler（対象ページ操作）
    1. HTTP Sampler（ログアウト）
        * **BeanShell PostProcessor**（ログアウト済みのCookieをConcurrentHashMapから削除）
1. **TearDown Thread Group**
    1. **BeanShell Sampler**（While Controller用変数の設定）
    1. **While Controller**（ログアウトが済んでいないCookieがある間ループ）
        1. **HTTP Sampler**（ログアウト）
            * **BeanShell PreProcessor**（ConcurrentHashMapから1つCookieを取り出してセット）

太字が、元のテスト計画から増えた部分です。だいぶ複雑になってしまいますが、一旦作成してしまえばほとんどコピペで使い回し可能なのでまあ許容範囲かと思います。

## 注意点など

* 「PostProcessor（発行されたCookieをリストに追加）」で `CookieManager#getCookies()` の戻り値を保存していますが、これは「この時点のCookieの値」ではなく「Cookieストアへの参照」なので、中身はスレッドの動作中に（Cookieが変更される度に）書き換えられていき、TearDown Thread Group内から参照する時点では、メインのThread Groupでの最終的なCookieの状態に変わっています。この結果として、（RailsのCookie Storeのように）セッションCookieがリクエスト毎に変わっていく場合などでも正しく動作します。