背景
クラウド基盤におけるビッグデータ活用への期待が高まる中、高速化、耐障害性へのスケーラビリティと共に、非同期処理を前提としたアーキテクチャが注目されます。
単純なモデルとしても、データの蓄積、加工、出力がすべて非同期で行われ、テストの自動化と継続的な実行を前提とする開発プロセスにおいては、Inputに対するOutputをテストするだけの単純なUnit Testだけでは安心できません。 が、どんなシステムにおいてもCIを自動化した上で開発を進めたいものです。
実現したいこと
GitにPushされたらJenkinsでBuild&Deployした上でこれを実現するのが望ましく、更に何がNGになったのかテスト結果を見てすぐにわかると嬉しいですね。
でも散るのは嫌
S3にはこのツール(プラグイン)を使い、SSHログインからのコマンド実行は別のツール、JSONのAssertionはまた別のツールなどという構成は避けたいところです。という訳でいくつかのOSSを調べた結果、今回はFitNesseを採用してみました。
FitNesseとは
Wikiでテストケースを書いて、実行するとAssertionした結果を表示してくれる受け入れテストツールです。
これの良いところは拡張性の高さ。
試験するためにFixtureというものが必要なのですが、これを自作することができます。
ColumnFixtureというクラスを拡張したクラスを作れば、Wikiからこのクラスのインスタンスに入力データを渡せるようになり、メソッドの実行も指示できるので、Javaでできることは何でもできてしまいます。
更にプラグインがあるのでJenkinsと連動させることができます。
Fixtureを探す
RestFixtureというものがありました。 Rest API叩いてレスポンスをJSONで解釈し、JavaScriptでAssertionが書けるようです。
今回のミッション
JenkinsでJOB実行 → FitNesseで全ての処理を実行 → Jenkinsで結果を確認
が実現できれば今回やりたいことの達成となります。 テスト用のダミーサーバーを用意して試してみます。
Fixture実装
実装はIntelliJ + Maven プロジェクトを使います。 RestFixtureは別jarのままでもよいですが、fat jarにしてみます。
- pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>acceptance-test</groupId>
<artifactId>fit-extention</artifactId>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>2.6</version>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.fitnesse</groupId>
<artifactId>fitnesse</artifactId>
<version>20151230</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>smartrics.restfixture</groupId>
<artifactId>smartrics-RestFixture</artifactId>
<version>4.1</version>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk</artifactId>
<version>1.10.66</version>
</dependency>
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.53</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
- bucket の src を同じ bucket の dest として複製する Fixture
package jp.co.atware.swat.test.fit.fixture;
import com.amazonaws.auth.EnvironmentVariableCredentialsProvider;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.CopyObjectResult;
import fit.ColumnFixture;
public class S3FileCopyFixture extends ColumnFixture {
public String bucket;
public String src;
public String dest;
public void setBucket(String bucket) {
this.bucket = bucket;
}
public void setSrc(String src) {
this.src = src;
}
public void setDest(String dest) {
this.dest = dest;
}
public String copy() {
AmazonS3Client client = new AmazonS3Client(new EnvironmentVariableCredentialsProvider());
CopyObjectResult result = client.copyObject(bucket, src, bucket, dest);
return result == null ? "NG" : "OK";
}
}
- bucket の name を削除する Fixture
package jp.co.atware.swat.test.fit.fixture;
import com.amazonaws.auth.EnvironmentVariableCredentialsProvider;
import com.amazonaws.services.s3.AmazonS3Client;
import fit.ColumnFixture;
public class S3FileDeleteFixture extends ColumnFixture {
public String bucket;
public String name;
public void setBucket(String bucket) {
this.bucket = bucket;
}
public void setName(String name) {
this.name = name;
}
public String delete() {
AmazonS3Client client = new AmazonS3Client(new EnvironmentVariableCredentialsProvider());
client.deleteObject(bucket, name);
return "OK";
}
}
- millisec ミリ秒待つ Fixture
package jp.co.atware.swat.test.fit.fixture;
import fit.ColumnFixture;
public class WaitFixture extends ColumnFixture{
public long millisec;
public void setMillisec(long millisec) {
this.millisec = millisec;
}
public String exec() throws Exception {
Thread.sleep(millisec);
return "OK";
}
}
- user で pem を使って host にSSHログインして command を実行する Fixture
package jp.co.atware.swat.test.fit.fixture;
import com.amazonaws.util.IOUtils;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import fit.ColumnFixture;
import java.io.InputStream;
import java.util.Hashtable;
public class SSHCommandFixture extends ColumnFixture {
public String user;
public String host;
public String pem;
public String command;
public void setUser(String user) {
this.user = user;
}
public void setHost(String host) {
this.host = host;
}
public void setPem(String pem) {
this.pem = pem;
}
public void setCommand(String command) {
this.command = command;
}
public String exec() throws Exception {
JSch jsch = new JSch();
jsch.addIdentity(pem);
Hashtable config = new Hashtable();
config.put("StrictHostKeyChecking", "no");
Session session = jsch.getSession(user, host, 22);
session.setConfig(config);
session.connect();
ChannelExec channel = (ChannelExec) session.openChannel("exec");
channel.setCommand(command);
channel.connect();
InputStream in = channel.getInputStream();
int exitStatus;
try {
while (true) {
System.out.print(IOUtils.toString(in));
if (channel.isClosed()) {
exitStatus = channel.getExitStatus();
break;
}
Thread.sleep(500);
}
} finally {
if (channel.isClosed() == false) {
channel.disconnect();
}
session.disconnect();
}
return exitStatus == 0 ? "OK" : "NG";
}
}
Wikiでテストケースを書く
Jenkinsと連携させる前に、fitnesse-standalone.jar をDLしてきて起動し、テストケースを書きます。
java -jar ./fitnesse-standalone.jar -p 8088 -e 0
-p オプションでポート指定。Jenkinsを8080で動かしたいので、8088としておく。
-e オプションはzipファイル生成をさせないために指定します。
(FitNesseはWikiを編集して保存する度にバックアップとしてzipファイルを生成してしまうので、これが邪魔なので排除)
!path /Users/Chiba/Dev/IdeaProjects/fit-extension/target/fit-extention-1.0-SNAPSHOT-jar-with-dependencies.jar
!define EC2_server {ec2-52-36-35-37.us-west-2.compute.amazonaws.com}
!define TEST_SYSTEM {slim}
!4 EC2にSSHログインしてダミーサーバーの起動シェルスクリプトを実行する
!|jp.co.atware.swat.test.fit.fixture.SSHCommandFixture|
| user | pem | host | command | exec? |
| ec2-user | /Users/Chiba/Desktop/ec2/chiba.pem | ${EC2_server} | cd dummy_server;sh ./start_dummy.sh | OK |
!4 3秒待つ
!|jp.co.atware.swat.test.fit.fixture.WaitFixture|
| millisec | exec? |
| 3000 | OK |
!4 S3のファイルをコピーする(AWS Lambdaによってダミーサーバーに通知される)
!|jp.co.atware.swat.test.fit.fixture.S3FileCopyFixture|
| bucket | src | dest | copy? |
| fitnesse.trial | input/input_data15.csv | input/chiba_input_data15.csv | OK |
!4 3秒待つ
!|jp.co.atware.swat.test.fit.fixture.WaitFixture|
| millisec | exec? |
| 3000 | OK |
!4 起動したサーバーに対してJSONをGETし、その内容をValidationする
|!-Table:smartrics.rest.fitnesse.fixture.RestFixture-! | http://${EC2_server}:8080 |
| setHeader |!-Content-Type:application/json;charset=UTF-8-!|
| GET | /json | 200 | | |
| let | | js |response.jsonbody.name | Deep Impact |
| let | | js |response.jsonbody.age | 13 |
| let | | js | response.jsonbody.children != null | true |
| let | | js | response.jsonbody.children.length | 2.0 |
| let | | js | response.jsonbody.children[0].name | Gentildonna |
| let | | js | response.jsonbody.children[0].age | 7 |
| let | | js | response.jsonbody.children[0].children != null | true |
| let | | js | response.jsonbody.children[0].children.length | 0.0 |
| let | | js | response.jsonbody.children[1].name | Harp Star |
| let | | js | response.jsonbody.children[1].age | 5 |
| let | | js | response.jsonbody.children[1].children != null | true |
| let | | js | response.jsonbody.children[1].children.length | 0.0 |
!4 EC2にSSHログインしてダミーサーバーの停止シェルスクリプトを実行する
!|jp.co.atware.swat.test.fit.fixture.SSHCommandFixture|
| user | pem | host | command | exec? |
| ec2-user | /Users/Chiba/Desktop/ec2/chiba.pem | ${EC2_server} | cd dummy_server;sh ./stop_dummy.sh | OK |
!4 S3のコピーしたファイルを削除する
!| jp.co.atware.swat.test.fit.fixture.S3FileDeleteFixture |
| bucket | name | delete? |
| fitnesse.trial | input/chiba_input_data15.csv | OK |
と書くと、このように表示されます。 ここで、
| let | | js |response.jsonbody.age | 13 |
としている箇所は、実際のデータでは14が返ってきます。 成功した結果を確認してもあまり意味ないので、故意にAssertionエラーを発生させてみます。
Jenkinsと連動させる
一旦、FitNesseを停止し、今度はJenkins。 JOBを作って、FitNesseの設定をします。
最終確認
JenkinsでJOB実行
JOBを実行すると、
結果を確認。
詳細を見る。
Assertionエラーが目立っていい感じです。
ダミーサーバーログを確認
起動 → POSTでのファイル名通知 → 停止 に成功しています。
[ec2-user@ip-172-31-42-153 dummy_server]$ cat dummy.log
INFO [2016-04-07 17:13:28,555] org.eclipse.jetty.util.log: Logging initialized @2091ms
INFO [2016-04-07 17:13:28,694] io.dropwizard.server.ServerFactory: Starting dummy-server
INFO [2016-04-07 17:13:28,703] io.dropwizard.server.DefaultServerFactory: Registering jersey handler with root path prefix: /
INFO [2016-04-07 17:13:28,722] io.dropwizard.server.DefaultServerFactory: Registering admin handler with root path prefix: /
INFO [2016-04-07 17:13:28,795] org.eclipse.jetty.setuid.SetUIDListener: Opened application@56f6d40b{HTTP/1.1,[http/1.1]}{0.0.0.0:8080}
INFO [2016-04-07 17:13:28,795] org.eclipse.jetty.setuid.SetUIDListener: Opened admin@36676c1a{HTTP/1.1,[http/1.1]}{0.0.0.0:8081}
INFO [2016-04-07 17:13:28,797] org.eclipse.jetty.server.Server: jetty-9.3.z-SNAPSHOT
INFO [2016-04-07 17:13:29,733] io.dropwizard.jersey.DropwizardResourceConfig: The following paths were found for the configured resources:
GET /json (jp.co.atware.swat.test.lambda.dummy.RespondFixedJsonResource)
POST /print (jp.co.atware.swat.test.lambda.dummy.PrintPostedBodyResource)
WARN [2016-04-07 17:13:29,738] org.glassfish.jersey.internal.Errors: The following warnings have been detected: WARNING: A HTTP GET method, public jp.co.atware.swat.test.lambda.dummy.Horse jp.co.atware.swat.test.lambda.dummy.RespondFixedJsonResource.get(java.lang.String), should not consume any entity.
INFO [2016-04-07 17:13:29,739] org.eclipse.jetty.server.handler.ContextHandler: Started i.d.j.MutableServletContextHandler@7cab1508{/,null,AVAILABLE}
INFO [2016-04-07 17:13:29,759] io.dropwizard.setup.AdminEnvironment: tasks =
POST /tasks/log-level (io.dropwizard.servlets.tasks.LogConfigurationTask)
POST /tasks/gc (io.dropwizard.servlets.tasks.GarbageCollectionTask)
INFO [2016-04-07 17:13:29,764] org.eclipse.jetty.server.handler.ContextHandler: Started i.d.j.MutableServletContextHandler@27b000f7{/,null,AVAILABLE}
INFO [2016-04-07 17:13:29,776] org.eclipse.jetty.server.ServerConnector: Started application@56f6d40b{HTTP/1.1,[http/1.1]}{0.0.0.0:8080}
INFO [2016-04-07 17:13:29,777] org.eclipse.jetty.server.ServerConnector: Started admin@36676c1a{HTTP/1.1,[http/1.1]}{0.0.0.0:8081}
INFO [2016-04-07 17:13:29,777] org.eclipse.jetty.server.Server: Started @3314ms
##############################
## Posted Body ##
##############################
{"keys":["input/chiba_input_data15.csv"]}
--------------------------------------------------
52.196.101.69 - - [07/Apr/2016:08:13:31 +0000] "POST /print HTTP/1.1" 204 0 "-" "Google-HTTP-Java-Client/1.21.0 (gzip)" 97
122.211.45.146 - - [07/Apr/2016:08:13:33 +0000] "GET /json HTTP/1.1" 200 132 "-" "Jakarta Commons-HttpClient/3.1" 52
INFO [2016-04-07 17:13:35,435] org.eclipse.jetty.server.ServerConnector: Stopped application@56f6d40b{HTTP/1.1,[http/1.1]}{0.0.0.0:8080}
INFO [2016-04-07 17:13:35,436] org.eclipse.jetty.server.ServerConnector: Stopped admin@36676c1a{HTTP/1.1,[http/1.1]}{0.0.0.0:8081}
INFO [2016-04-07 17:13:35,439] org.eclipse.jetty.server.handler.ContextHandler: Stopped i.d.j.MutableServletContextHandler@27b000f7{/,null,UNAVAILABLE}
INFO [2016-04-07 17:13:35,447] org.eclipse.jetty.server.handler.ContextHandler: Stopped i.d.j.MutableServletContextHandler@7cab1508{/,null,UNAVAILABLE}
S3
chiba_input_data15.csv が存在しないので、正しく削除されている。
おまけ
JSONのAssertionの記述を一々全部書くのが面倒という方には、JsonRestFixtureというものが公開されています。
今回は導入していませんが、レスポンスのJSONをファイル保存して、他のファイル又はWikiに書いたJSONとのAssertionができるらしいです。