はじめに
記事を書くきっかけ
私はJavaで書かれたパッケージソフトの保守開発をしています。その一部には自動テストが存在しないアプリケーションがあり、変更後の動作確認が大変でした。近年、「Clean Architecture 達人に学ぶソフトウェアの構造と設計」という本を読み、特に「テスト可能な構造」の部分に感銘を受けました。そして、その考え方を自動テストが存在しないアプリケーションに適用し、自動テストができるようになりました。そのノウハウを公開することにより、同じような問題を抱えている人たちの助けになればと思い、記事を書きました。
記事の概要
Clean Architecture の考え方を応用し、自動テストがないアプリケーションを自動テスト可能な構造へ変える方法が、サンプルコードを使って説明されています。
問題の確認と解決方法の考え方
自動テストがないアプリケーションの問題は何か、クリーンアーキテクチャの考え方を使ってどのように自動テスト可能な構造へ変更するのか、概要を説明します。
自動テストがないアプリケーションの保守開発における問題
業務上の振る舞いがユニットテストできるようになっていないアプリケーションの保守開発には以下のような問題があります。
- 自動テストがないため、コード変更に伴う動作確認は実際に動かさなければならない(テストのコストが高い)
- 動作確認が大変なため、変更が敬遠されがち
Clean Architecture とは
アーキテクチャという言葉を使っているので、私は最初、例えばマイクロサービスアーキテクチャなど、分散システムにおけるノードの分け方に関する話題か何かと思いましたが、読んでみるとそうではなく、ソフトウェアの設計思想のようなものだと思いました。私の考えでは、その要点は以下の1点です。
- アプリケーションの重要な部分が、そうではない部分(「詳細」)に依存しないようにする
その結果、アプリケーションの重要な部分が自動テスト可能になります。
「詳細」とは何か
重要な部分とは、アプリケーションの業務上の振る舞いの事です。つまり外部仕様の事です。反対に「詳細」とは何でしょうか。Clean Architecture の本で紹介されていたものに加え、安定した自動テストの実施を考慮して私が独自に追加したものをまとめると、具体的には以下のような処理が「詳細」にあたると考えています。
- メモリの外へのアクセス
- 標準入出力
- OSのファイルシステムへのアクセス
- ネットワーク通信
- 画面の表示
- 実行環境に依存する処理
- 環境変数の参照
- 挙動が毎回変わる処理
- UUIDの生成
- 現在日時の生成
これらの処理に業務上の振る舞いが依存しないようにアプリケーションの構造を変換できれば、業務上の振る舞いのみを自動テストできるようになります。
依存関係の逆転
制御の流れを素直に実装すると、業務上の振る舞いが「詳細」に依存してしまいます。例えば、RDBからデータを取得して加工する処理の場合、業務上の振る舞いとしては、データストアからデータを取得した後どのように加工するかにしか関心がないのに、実際にはRDBへアクセスするためのコード(コネクションのオープン、SQLの実行、など)が必要なので、それに依存してしまいます。
このような場合、業務上の振る舞い側で定義されたインターフェースやデータ構造を介して間接的に「詳細」へアクセスさせます。これにより、変更前は振る舞いが「詳細」に依存していましたが、変更後は「詳細」が振る舞い側のインターフェースを実装するようになるため、依存の向きが逆転します。「詳細」が振る舞いのプラグインになる、と表現することもできます。具体例は後ほど紹介します。
具体例:ハイパーリンク収集アプリケーション
抽象的な話をしてきましたが、ここからは具体的に自動テストがないアプリケーションをテスト可能な構造へ変換します。
例として、ハイパーリンクを収集してファイルへ保存するコンソールアプリケーションを考えます。
アプリケーションの概要
- アプリケーションはユーザから指定されたURLへアクセスし、取得されたHTMLページからハイパーリンクのラベルとURLを抽出し、ユーザから指定されたディレクトリへCSV形式で保存する。
- ファイル名は実行日時を含む。命名規則は
link_yyyyMMdd_HHmmss.csv
とする。
変更前のコード
自動テストの事は考えず、素直に実装すると、例えば以下のようなコードになります。
public class Main {
private static final Logger logger = LoggerFactory.getLogger(Main.class);
/**
* 指定URLのHTMLからハイパーリンクを抽出し、指定ディレクトリへCSVファイルとして出力するアプリケーション
*/
public static void main(String[] args) {
if (args.length != 2) {
logger.info("第一引数にURL,第二引数に出力先ディレクトリのパスを指定してください");
return;
}
try {
// 指定URLのHTMLからハイパーリンクを抽出
final String urlStr = args[0];
final URL url;
try {
url = new URL(urlStr);
} catch (MalformedURLException e) {
logger.error("第一引数のURLが不正です。 : {}", urlStr);
return;
}
final List<HyperLink> hyperLinks;
try {
hyperLinks = extractHyperLinks(url);
} catch (IOException e) {
logger.error("HTMLページの取得に失敗しました。", e);
return;
} catch (HtmlParseException e) {
logger.error("HTMLページの解析に失敗しました。{}", e.getMessage());
return;
}
// 結果書き込み
final String dirPathStr = args[1];
final Path dirPath;
try {
dirPath = Paths.get(dirPathStr);
} catch (InvalidPathException e) {
logger.error("第二引数に指定されたパスが不正です。{}", dirPathStr);
return;
}
if (Files.notExists(dirPath)) {
logger.error("第二引数に指定されたパスが存在しません。{}", dirPathStr);
return;
}
if (!Files.isDirectory(dirPath)) {
logger.error("第二引数に指定されたパスはディレクトリのパスではありません。{}", dirPathStr);
return;
}
// 現在日時を含むファイル名の生成
final String fileName = getFileNameAtThisTime();
final Path filePath = Paths.get(dirPathStr, fileName);
try {
writeHyperLinks(hyperLinks, filePath);
} catch (IOException e) {
logger.error("{}へ結果の書き込みに失敗しました。", filePath, e);
return;
}
logger.info("{}へ結果を書き込みました。", filePath);
} catch (Exception e) {
logger.error("予期せぬエラーが発生しました", e);
}
}
private static List<HyperLink> extractHyperLinks(final URL url) throws IOException, HtmlParseException {
final List<HyperLink> result = new ArrayList<>();
try (InputStream inputStream = new BufferedInputStream(url.openStream())) {
// SAXパーサなどでHTMLファイルを解析し、ハイパーリンクを抽出する
result.add(new HyperLink("http://example.com/", "サンプルURL"));
}
return result;
}
private static String getFileNameAtThisTime() {
final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
return "links_" + LocalDateTime.now().format(dtf) + ".csv";
}
private static void writeHyperLinks(final List<HyperLink> hyperLinks, final Path filePath) throws IOException {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new BufferedOutputStream(
new FileOutputStream(filePath.toFile())
)
, StandardCharsets.UTF_8
)
)
) {
pw.println("ラベル,URL"); // ヘッダ
for (HyperLink link : hyperLinks) {
pw.println(String.format("%s,%s", link.label(), link.url()));
}
}
}
}
この程度の複雑さのアプリケーションですら、異常系を考慮するとテストケースの数が多くなり、手動のテストは大変です。製品用のコードはもっと複雑ですので、もっとテストに手間がかかります。なんとしても自動テストができるようにしたくなります。
変更後のコード
変更の方針は先に述べた通り、依存関係の逆転を使って「詳細」にあたる部分を業務上の振る舞いから間接的に参照する構造へ変換し、その後、業務の振る舞いのテストを用意します。「詳細」にあたる部分はMockitoなどを使ってテストダブルオブジェクトで置き換えます。また、業務ロジックは目的別にクラスへ分割します。
引数の解釈
引数の解釈ロジックをクラスへ切り出します。今回のサンプルでは切り出す必要があまりないかもしれませんが、オプションなどが増えた場合、複雑になります。テストできるように切り出します。
class ArgumentParser {
private final List<String> args;
ArgumentParser(List<String> args) {
this.args = args;
}
public boolean isLegal() {
return args.size() == 2;
}
public String getUrl() {
return args.get(0);
}
public String getDirectoryPath() {
return args.get(1);
}
}
HTTP通信
HTTP通信する部分は「詳細」に該当するため、依存関係の逆転を使ってアプリケーションから切り離す必要があります。具体的には、HTTPコネクションの生成とコネクション自体をAbstractFactoryパターンを使ってインターフェースで参照するように変更します。
interface UrlConnection {
InputStream getInputStream() throws IOException;
}
interface UrlConnectionFactory {
UrlConnection create(final String url) throws IOException;
}
実装クラスは以下の通りです。
class UrlConnectionImpl implements UrlConnection {
private final URL url;
UrlConnectionImpl(final URL url) {
this.url = url;
}
@Override
public InputStream getInputStream() throws IOException {
return url.openStream();
}
}
class UrlConnectionFactoryImpl implements UrlConnectionFactory {
@Override
public UrlConnection create(final String url) throws IOException {
return new UrlConnectionImpl(new URL(url));
}
}
ハイパーリンクの抽出
HTMLからハイパーリンクを抽出するロジックは、様々なテストケースが考えられるため、個別にテストしたい部分です。「詳細」に該当しないため、依存関係の逆転は必須ではありませんが、複雑なロジックを本体から切り離して考えたいため、インターフェースを用意し、それを本体から参照するように変更します。
interface HyperLinkExtractor {
// パーサに対する最も汎用的な入力はストリームなのでInputStreamを入力としました
// 場合によってはReaderでもいいと思います
List<HyperLink> extract(final InputStream inputStream) throws IOException, HtmlParseException;
}
class HyperLinkExtractorImpl implements HyperLinkExtractor {
private final Logger logger;
HyperLinkExtractorImpl(final Logger logger) {
this.logger = logger;
}
@Override
public List<HyperLink> extract(final InputStream inputStream) throws IOException, HtmlParseException {
final List<HyperLink> result = new ArrayList<>();
// TODO SAXパーサなどでHTMLファイルを解析し、ハイパーリンクを抽出する
return result;
}
}
抽出されたハイパーリンクを表す値オブジェクトです。今回のサンプルはJava17でを書いているため、Recordクラスを使います。
record HyperLink(String url, String label) {
}
セマンティック(意味上の)エラーのために、独自の検査例外を用意します。
public class HtmlParseException extends Exception {
public HtmlParseException(final String message) {
super(message);
}
}
ファイルシステムに対する入出力
ファイルシステムに対する入出力は「詳細」に該当するため、依存関係の逆転を使って本体から切り離す必要があります。今回の例では、HTTP通信とは違い、AbstractFactoryパターンを適用せずに対応できます。
interface FileSystemAccessor {
OutputStream getOutputStream(final Path path) throws IOException;
}
class FileSystemAccessorImpl implements FileSystemAccessor {
@Override
public OutputStream getOutputStream(final Path path) throws IOException {
return new FileOutputStream(path.toFile());
}
}
実行日時に応じたファイル名の生成
実行日時に応じてファイル名をlink_yyyyMMdd_HHmmss.csv
のフォーマットで生成する必要があります。このロジックの実行日時を返す部分は「詳細」に該当しますので、インターフェースを使って参照するように変更します。
class FileNameGenerator {
private final Supplier<LocalDateTime> timestampSupplier;
public FileNameGenerator(final Supplier<LocalDateTime> timestampSupplier) {
this.timestampSupplier = timestampSupplier;
}
public String generate() {
final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
return "links_" + timestampSupplier.get().format(dtf) + ".csv";
}
}
CSV出力
個別にテストするため、別クラスへ切り出します。今回はPrintWriterに対するデコレータパターンとして設計します。
class CsvWriter implements Closeable {
private static final String DELIMITER = ",";
private final PrintWriter printWriter;
public CsvWriter(final PrintWriter printWriter) {
this.printWriter = printWriter;
}
public void write(String... record) {
printWriter.println(String.join(DELIMITER, record));
}
public void flush() {
printWriter.flush();
}
@Override
public void close() {
printWriter.close();
}
}
アプリケーション本体
複雑なロジックや「詳細」を直接参照しないように、アプリケーションの本体を作り直します。
class Controller {
private final Logger logger;
private final UrlConnectionFactory urlConnectionFactory;
private final HyperLinkExtractor extractor;
private final FileNameGenerator fileNameGenerator;
private final FileSystemAccessor fileSystemAccessor;
Controller(final Logger logger,
final UrlConnectionFactory urlConnectionFactory,
final HyperLinkExtractor extractor,
final FileNameGenerator fileNameGenerator,
final FileSystemAccessor fileSystemAccessor) {
this.logger = logger;
this.urlConnectionFactory = urlConnectionFactory;
this.extractor = extractor;
this.fileNameGenerator = fileNameGenerator;
this.fileSystemAccessor = fileSystemAccessor;
}
public void execute(List<String> args) {
final ArgumentParser argumentParser = new ArgumentParser(args);
if (!argumentParser.isLegal()) {
logger.info("第一引数にURL,第二引数に出力先ディレクトリのパスを指定してください");
return;
}
// 指定URLのHTMLからハイパーリンクを抽出
final String url = argumentParser.getUrl();
final List<HyperLink> hyperLinks;
try {
hyperLinks = extractHyperLinks(url);
} catch (IOException e) {
logger.error("HTMLページの取得に失敗しました。", e);
return;
} catch (HtmlParseException e) {
logger.error("HTMLページの解析に失敗しました。{}", e.getMessage());
return;
}
// 現在日時を含むファイル名の生成
final String fileName = getFileNameAtThisTime();
final Path filePath = Paths.get(argumentParser.getDirectoryPath(), fileName);
try {
writeHyperLinks(hyperLinks, filePath);
} catch (IOException e) {
logger.error("{}へ結果の書き込みに失敗しました。", filePath, e);
return;
}
logger.info("{}へ結果を書き込みました。", filePath);
}
private List<HyperLink> extractHyperLinks(final String url) throws IOException, HtmlParseException {
UrlConnection connection = urlConnectionFactory.create(url);
try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) {
return extractor.extract(inputStream);
}
}
private String getFileNameAtThisTime() {
return fileNameGenerator.generate();
}
private void writeHyperLinks(final List<HyperLink> hyperLinks, final Path filePath) throws IOException {
try (CsvWriter csvWriter = new CsvWriter(
new PrintWriter(
new OutputStreamWriter(
new BufferedOutputStream(fileSystemAccessor.getOutputStream(filePath))
, StandardCharsets.UTF_8
)
)
)) {
csvWriter.write("ラベル", "URL");
for (HyperLink link : hyperLinks) {
csvWriter.write(link.label(), link.url());
}
}
}
}
Mainクラス
最後にMainクラスを作り直します。MainクラスはClean Architectureの概念図で最も外側に位置する、究極の「詳細」です。Mainクラスで依存関係の逆転に使われたインターフェースの実装クラスがアプリケーションの本体へ注入されます。(依存性の注入:Dependency Injection)
public class Main {
private static final Logger logger = LoggerFactory.getLogger(Main.class);
/**
* 指定URLのHTMLからハイパーリンクを抽出し、指定ディレクトリへCSVファイルとして出力するアプリケーション
*/
public static void main(String[] args) {
final Controller controller = new Controller(
logger,
new UrlConnectionFactoryImpl(),
new HyperLinkExtractorImpl(logger),
new FileNameGenerator(LocalDateTime::now),
new FileSystemAccessorImpl()
);
try {
controller.execute(Arrays.asList(args));
} catch (Exception e) {
logger.error("予期せぬエラーが発生しました", e);
}
}
}
Mainクラス以外のクラスはすべて自動テストが可能になりました。
おわりに
自動テストが存在しないアプリケーションの動作確認が大変な問題を、当該アプリケーションの構造を自動テスト可能な構造へ変換することによって解決する方法を紹介しました。
構造の変換にあたっては、以下の2つの工夫を紹介しました。
- アプリケーションを業務ロジックごとに分解する
- 「詳細」に対する依存の向きをインターフェースを使って逆転させる
これらの工夫によりアプリケーションが自動テスト可能になり、動作確認の手間が減ります。