JBehave+Maven+Eclipseを使った結合試験の自動化
What is JBehave?
JBehave is a framework for Behaviour-Driven Development(BDD).
http://jbehave.org/より
BDDをするためのフレームワークです。
試験シナリオをプレインテキストで記述し、それを処理するテストを記述することで試験をしていきます。試験シナリオをテキストで記述できるため、いわゆるお客様にシナリオを確認してもらいやすくなります。(お客様に書いてもらうこともできそうですが、ちょっと厳しいかと思います)
具体例
先日作ったjalo(https://github.com/tamurashingo/jalo)に結合試験を追加してみます。
ソースコードはhttps://github.com/tamurashingo/jalo/tree/feature/integration-testにブランチを切って上げてあります。
ディレクトリ構成
こんなディレクトリ構成になります。
jalo/ プロジェクトのルート
src/ ソースディレクトリ
main/ アプリ用
java/
resources/
test/ テスト用
java/
test/ UnitTest用(既存)
integration/ 受け入れ試験用(追加)
resources
.../ UnitTest用のリソース
stories/ 受け入れ試験用のシナリオ(追加)
pom.xmlの修正
JBehaveを動かすための設定を追加します。
Dependencyにあるjbehave-junit-runnerは、EclipseでJBehaveを動かす際にあると便利なので入れておきます。(後述)
...
<properties>
...
<jbehave.core.version>3.9.1</jbehave.core.version>
...
</properties>
<build>
<plugins>
...
<!-- JBehaveを使った試験 -->
<plugin>
<groupId>org.jbehave</groupId>
<artifactId>jbehave-maven-plugin</artifactId>
<version>${jbehave.core.version}</version>
<executions>
<execution>
<id>embeddable-stories</id>
<phase>integration-test</phase>
<configuration>
<includes>
<include>**/*Stories.java</include>
</includes>
<ignoreFailureInStories>true</ignoreFailureInStories>
<ignoreFailureInView>false</ignoreFailureInView>
<scope>test</scope>
</configuration>
<goals>
<goal>run-stories-as-embeddables</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- 試験結果のレポート -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>unpack-jbehave-site-resources</id>
<phase>generate-resources</phase>
<goals>
<goal>unpack</goal>
</goals>
<configuration>
<overwriteReleases>false</overwriteReleases>
<overwriteSnapshots>true</overwriteSnapshots>
<artifactItems>
<artifactItem>
<groupId>org.jbehave.site</groupId>
<artifactId>jbehave-site-resources</artifactId>
<version>3.1.1</version>
<type>zip</type>
<outputDirectory>${project.build.directory}/jbehave/view</outputDirectory>
</artifactItem>
</artifactItems>
</configuration>
</execution>
<execution>
<id>unpack-jbehave-reports-resources</id>
<phase>generate-resources</phase>
<goals>
<goal>unpack</goal>
</goals>
<configuration>
<overwriteReleases>false</overwriteReleases>
<overwriteSnapshots>true</overwriteSnapshots>
<artifactItems>
<artifactItem>
<groupId>org.jbehave</groupId>
<artifactId>jbehave-core</artifactId>
<version>${jbehave.core.version}</version>
<outputDirectory>${project.build.directory}/jbehave/view</outputDirectory>
<includes>**\/*.css,**\/*.ftl,**\/*.js</includes>
</artifactItem>
</artifactItems>
</configuration>
</execution>
</executions>
</plugin>
...
</plugins>
</build>
<dependencies>
...
<dependency>
<groupId>org.jbehave</groupId>
<artifactId>jbehave-core</artifactId>
<version>${jbehave.core.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>jbehave-junit-runner</artifactId>
<version>1.1.0</version>
<scope>test</scope>
</dependency>
...
</dependencies>
...
シナリオの作成
BDDは「要求仕様に近い形で、自然言語を併記しながらテストコードを記述(Wikipediaより)」します。
jaloを作ったときの要求はこんな感じです。
- java -jar jalo.jar でアプリを起動したい
- boot用xmlのみでアプリケーションを自動でダウンロードしてくる
- アプリ起動時にサーバ側が更新していたら、手元のアプリを自動更新したい
これを元にシナリオを書いてみます。
Scenario: 0001 設定ファイルからアプリケーションをダウンロード
Given 初期化
When テンポラリディレクトリの作成
When Bootファイル読み込み stories/boot0001.xml
Then 更新用URLが http://localhost/jalo/integration/data/boot0001 であること
Then 自動更新が 1 であること
When アプリケーションディレクトリの削除
When アプリケーション読み込み
Then アプリケーションのバージョンが 0.1 であること
Scenario: 0002 アプリケーションのアップデート
!-- 0001でダウンロードしたアプリケーションを引き続き使う
Given 初期化
When Bootファイル読み込み stories/boot0002.xml
When アプリケーション読み込み
Then 更新用URLが http://tamurashingo.github.io/jalo/integration/data/boot0002 であること
Then 自動更新が 1 であること
When アプリケーション読み込み
Then アプリケーションのバージョンが 0.2 であること
Scenario: 0003 アプリケーションを自動更新しない
!-- 0002で使用したアプリケーションを引き続き使う
When Bootファイル読み込み stories/boot0003.xml
Then 更新用URLが http://tamurashingo.github.io/jalo/integration/data/boot0003 であること
Then 自動更新が 0 であること
!-- アプリケーションを更新するかどうかの判断はMainで行っているため、結合試験ではここまで
Scenario: 0004 アプリケーションの更新をしない
!-- 0002で使用したアプリケーションを引き続き使う
!-- 0.2(ローカル) > 0.04(サーバ) なので更新しない
When Bootファイル読み込み stories/boot0004.xml
Then 更新用URLが http://tamurashingo.github.io/jalo/integration/data/boot0004 であること
Then 自動更新が 1 であること
When アプリケーション読み込み
Then アプリケーションのバージョンが 0.2 であること
Then tmpディレクトリのapp.xmlのバージョンが 0.04 であること
いわゆるお客様にシナリオを書いていただくのはちょっと厳しいかもしれませんが、シナリオを確認していただくには問題ないかと思います。
Stepの実装
シナリオを実行するJavaのコードを書いていきます。
アノテーションにシナリオで定義した文字列を書いていき、パラメータで受け取りたいところを $param のような形式で書いていきます。
パラメータは今回はStringですが、intとかdoubleとかもいけます。
public class BootSteps {
BootConfigBean bootConfig;
AppConfigBean appConfig;
/** テンポラリディレクトリ */
Path tempPath;
@BeforeStories
public void before() {
}
@Given("初期化")
public void initialize() {
}
@When("テンポラリディレクトリの作成")
public void createTempDir() throws Exception {
if (this.tempPath != null) {
deletePath(this.tempPath);
this.tempPath = null;
}
this.tempPath = Files.createTempDirectory(null);
}
@When("アプリケーションディレクトリの削除")
public void deleteAppDir() throws Exception {
Path appPath = this.tempPath.resolve(this.bootConfig.getApplicationDir());
deletePath(appPath);
}
@When("Bootファイル読み込み $boot")
public void loadBoot(String boot) throws Exception {
URL bootURI = BootSteps.class.getClassLoader().getResource(boot).toURI();
Path bootPath = Paths.get(bootURI);
// テンポラリディレクトリにbootファイルをコピー
Path tmpBootPath = Files.copy(bootPath, this.tempPath.resolve(bootPath.getFileName()), StandardCopyOption.REPLACE_EXISTING);
// テンポラリディレクトリのbootファイル名を得る
String tmpBootFile = tmpBootPath.toString();
// テンポラリディレクトリのbootファイルを読み込む
this.bootConfig = new BootConfigBean();
this.bootConfig.read(tmpBootFile);
}
@When("アプリケーション読み込み")
public void loadApp() throws Exception {
this.appConfig = AppConfigBean.createConfig(this.bootConfig);
this.appConfig = Main.update(this.bootConfig, this.appConfig);
}
@Then("更新用URLが $url であること")
public void checkURL(String url) {
assertThat(this.bootConfig.getUrl(), equalTo(url));
}
@Then("自動更新が $auto であること")
public void checkAutoUpdate(String auto) {
assertThat(this.bootConfig.isAutoUpdate(), equalTo(auto.equals("1")));
}
@Then("アプリケーションのバージョンが $version でること")
public void checkAppVersion(String version) throws Exception {
JaloRunner jalo = new JaloRunner(this.appConfig);
URLClassLoader loader = (URLClassLoader)jalo.createClassLoader();
Class<?> cls = Class.forName(this.appConfig.getMainClass(), true, loader);
Field f = cls.getField("version");
String value = (String)f.get(null);
assertThat(value, equalTo(version));
loader.close();
}
@Then("tmpディレクトリのapp.xmlのバージョンが $version であること")
public void checkTmpAppVersion(String version) throws Exception {
Path tmpAppFile = this.tempPath.resolve(this.bootConfig.getTmpDir()).resolve(ApppConfigBean.DEFAULT_FILENAME);
AppConfigBean tmpAppConfig = new AppConfigBean();
tmpAppConfig.read(tmpAppFile.toString());
assertThat(tmpAppConfig.getVersion(), equalTo(version));
}
@AfterStories
public void after() {
if (this.tempPath != null) {
deletePath(this.tempPath);
}
}
...
}
Storyの実装
storyファイルを読み込んで、Stepを実行するコードを書いていきます。
@RunWith(JUnitReportingRunner.class)
public class BootStories extends JUnitStories {
private final CrossReference xref = new CrossReference();
public BootStories() {
configuredEmbedder().embedderControls().doGenerateViewAfterStories(true).doIgnoreFailureInStories(true)
.doIgnoreFailureInView(true).useThreads(1).useStoryTimeoutInSecs(60);
}
@Override
public Configuration configuration() {
Class<? extends Embeddable> embeddableClass = this.getClass();
Properties viewResources = new Properties();
viewResources.put("decorateNonHtml", "true");
// Start from default ParameterConverters instance
ParameterConverters parameterConverters = new ParameterConverters();
// factory to allow parameter conversion and loading from external resources (used by StoryParser too)
ExamplesTableFactory examplesTableFactory = new ExamplesTableFactory(new LocalizedKeywords(), new LoadFromClasspath(embeddableClass), parameterConverters);
// add custom converters
parameterConverters.addConverters(new DateConverter(new SimpleDateFormat("yyyy-MM-dd")),
new ExamplesTableConverter(examplesTableFactory));
return new MostUsefulConfiguration()
.useStoryControls(new StoryControls().doDryRun(false).doSkipScenariosAfterFailure(false))
.useStoryLoader(new LoadFromClasspath(embeddableClass))
.useStoryParser(new RegexStoryParser(examplesTableFactory))
.useStoryReporterBuilder(new StoryReporterBuilder()
.withCodeLocation(CodeLocations.codeLocationFromClass(embeddableClass))
.withDefaultFormats()
.withViewResources(viewResources)
.withFormats(CONSOLE, TXT, HTML, XML)
.withFailureTrace(true).withFailureTraceCompression(true).withCrossReference(xref))
.useParameterConverters(parameterConverters)
.useStepMonitor(xref.getStepMonitor());
}
@Override
public InjectableStepsFactory stepsFactory() {
return new InstanceStepsFactory(configuration(), new BootSteps());
}
@Override
protected List<String> storyPaths() {
return Arrays.asList("stories/boot.story");
}
}
JUnitStoriesはJUnitとして動かすことができるので、Eclipseで右クリック->Run as->JUnit Testすることができます。
@RunWith(JUnitReportingRunner.class)をつけないと実行結果がさっぱり分からないので、Eclipseで試験を動かす際はつけておいたほうが良いです。
@RunWith(JUnitReportingRunner.class)をつけない場合
@RunWith(JUnitReportingRunner.class)をつけた場合
Maven
Mavenで実行するには
mvn integration-test
で実行します。
Mavenで実行すると target/jbehave/view ディレクトリに結果がHTML等で作られるので、必要に応じて見てみてください。
Trouble shoot
Eclipseで動かない
こんなログが出てstoryが実行されない場合があります。
java.lang.AbstractMethodError
at org.jbehave.core.reporters.DelegatingStoryReporter.lifecyle(DelegatingStoryReporter.java:79)
at org.jbehave.core.reporters.ConcurrentStoryReporter.lifecyle(ConcurrentStoryReporter.java:137)
at org.jbehave.core.embedder.StoryRunner.runCancellable(StoryRunner.java:277)
at org.jbehave.core.embedder.StoryRunner.run(StoryRunner.java:220)
at org.jbehave.core.embedder.StoryRunner.run(StoryRunner.java:181)
at org.jbehave.core.embedder.StoryManager$EnqueuedStory.call(StoryManager.java:235)
at org.jbehave.core.embedder.StoryManager$EnqueuedStory.call(StoryManager.java:207)
at java.util.concurrent.FutureTask$Sync.innerRun(Unknown Source)
at java.util.concurrent.FutureTask.run(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.lang.Thread.run(Unknown Source)
これは、jbehave-junit-runnerのバージョンがjbehave-coreのバージョンに対応していない場合に発生するようです。
とりあえずどちらも最新バージョンにすれば動くので、バージョンをそろえましょう。
Mavenで実行されないっぽい。。。
ログを見ると動いていないようなときがあります。
[INFO] --- jbehave-maven-plugin:3.9.1:run-stories-as-embeddables (embeddable-stories) @ jalo ---
[INFO] Running stories as embeddables using embedder Embedder[storyMapper=StoryMapper,storyRunner=StoryRunner,embedderMonitor=MavenEmbedderMonitor,classLoader=EmbedderClassLoader[urls=[/home/shingo/workspace/jalo/target/classes/],parent=ClassRealm[plugin>org.jbehave:jbehave-maven-plugin:3.9.1, parent: sun.misc.Launcher$AppClassLoader@141d19]],embedderControls=UnmodifiableEmbedderControls[EmbedderControls[batch=false,skip=false,generateViewAfterStories=true,ignoreFailureInStories=true,ignoreFailureInView=false,verboseFailures=false,verboseFiltering=false,storyTimeoutInSecs=300,failOnStoryTimeout=false,threads=1]],embedderFailureStrategy=<null>,configuration=org.jbehave.core.configuration.MostUsefulConfiguration@2107e4c4,candidateSteps=<null>,stepsFactory=<null>,metaFilters=<null>,systemProperties=<null>,executorService=<null>,executorServiceCreated=false,storyManager=<null>]
[INFO] Found class names: []
[INFO] Using controls UnmodifiableEmbedderControls[EmbedderControls[batch=false,skip=false,generateViewAfterStories=true,ignoreFailureInStories=true,ignoreFailureInView=false,verboseFailures=false,verboseFiltering=false,storyTimeoutInSecs=300,failOnStoryTimeout=false,threads=1]]
こういう場合はクラスパスが不足していないか確認します。
今回integration-test用に書いたコードは src/test/ 配下に置いているため、クラスパスに target/test-classes/ が必要です。
そのため、pom.xmlにscopeを記述します。
...
<build>
...
<plugin>
<groupId>org.jbehave</groupId>
<artifactId>jbehave-maven-plugin</artifactId>
<executions>
<execution>
...
<configuration>
...
<scope>test</scope> <!-- これ -->
</configuration>
</execution>
</executions>
</plugin>
...
</build>
...