本ブログではSAP Cloud Application Programming Model(SAP CAP)におけるテストの考え方と、単体テストおよび内部結合テストのテストコードの書き方についてご紹介します。CAP on JavaとCAP on Node.jsでテストの書き方が異なりますが、本ブログではCAP on Node.jsでのテストについて触れてまいります。本ブログの内容はトライアル環境でも実施可能なので、ぜひお読みいただきながら手を動かしていただき、実際にテストコードを作成してみてください。
本ブログはSAP CAPフレームワークを利用してアプリケーションを開発されたことのある方向けの内容となっております。SAP CAPフレームワークについてご存知ない方はこちらのSAP CAP公式ドキュメントを、SAP CAPフレームワークを利用したアプリ開発をハンズオンで試してみたい方はこちらのチュートリアルをご参照ください。
みなさんはCAPアプリケーションに限らず、クラウドアプリケーションでテストコードを書かれたことはあるでしょうか?テストコードは開発した成果物の挙動を保証するための非常に重要なものですが、機能の実装よりも後回しになり、開発の遅れ等に伴って結局テストコードにまで手が回らずに終わってしまい、単体テストをスキップして検証環境にデプロイしたアプリケーションの画面を実際に動かして手動でテストを行うケースも多いかと思います。
「コストも時間もかかる手動テストだけでアプリケーションを保証し切る」のは、一度作ったアプリケーションは塩漬けしてほとんど手を加えることがない従来の完全なウォーターフォール型の開発ではまだ理にかなっているかもしれません。しかし、クラウドアプリケーションではそうはいきません。ランタイムやライブラリのバージョンアップやセキュリティパッチ適用などで、大なり小なりアプリケーションを継続的に修正することが求められます。言わばアプリケーションの新陳代謝が必要と言えますが、そのたびにテストを手動で実行するのは現実的ではありません。
このセクションではみなさんにテストの自動化についてモチベーションを持っていただくために、さらに考え方やメリットについて触れていきます。
一言にテストといってもいろいろあります。ウォーターフォール型の開発では単体テストや結合テスト、システムテスト、受け入れテストを通じて開発工程の各ステップを検証していきます。これらのテストを全て自動化するのかというと、答えはNoです。
自動化したテストは「いつでも、どこでも、誰でも」実行できることが好ましいです。自動化したテストは開発者がコマンドで実行するほか、ビルド時に実行させたり、それをCI/CDパイプラインの中で自動的に実行させることができます。仮に外部結合テストやシステムテストを自動化させた場合、テスト実行時に外部システムとのデータ連携も実行されてしまいます。この場合、テストを実行する環境から外部システムへアクセスできなかったり、外部システムが停止しているとそもそもテストを実行できません。また扱うデータが本番相当のものだとそのデータをテストにより改変してしまう可能性もあります。これらは「いつでも、どこでも、誰でも」のポリシーに矛盾してしまいます。
また、自動テストはソースコードに記述したロジックの正しさを検証、保証して次のステップへ確実に繋げていくものになります。このことから、自動化すべきテストは単体テストと内部結合テストであると考えることができます。
また、すべてのクラスやメソッドを漏れなく自動テストで守り切るべきかというと、それもNoです。必要な部分、効果の大きい部分に絞って効率的にテストコードを書くべきです。例えば、クリーンアーキテクチャに従って適切に責務を分離してソースコードを記述した場合、DBや外部システムへの接続処理はGateway(Repogitory)層のクラスに、APIを公開する部分はController層のクラスに分離できます。適切に分離できていればこれらのクラスにはビジネスロジックは記述されていないはずです。単体テストではロジックの正しさを保証するためのものなので、Gateway層やController層のテストを省くことができます。
クリーンアーキテクチャ[引用元:The Clean Code Blog by Robert C. Martin (Uncle Bob) (https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)]
テストを自動化することでアプリケーションの新陳代謝(バージョンアップやパッチ適用)に対応できることに触れましたが、他にも開発者にとって嬉しいメリットがあります。
テストコードは実際の開発物に対応する形で作成していきます。特に単体テストはクラスやメソッドの単位で作り込んでいきます。対象のメソッドへのあらゆる入力に対するテストコードを記載することで、そのメソッドにバグが混入されることを防げます。例えば、パラメータを1つ持っているメソッドに対するテストとして、そのパラメータに「正常値(実際に実行した時に渡されると想定される値)」、「異常値(型が違う値や、出鱈目な文字列など)」、「Null値」などのパターンでテストを作成すれば、あらゆる入力に対して想定した結果が得られることを保証できます。またそのメソッドを「保証された部品」として扱うことができるようになるので、他のコンポーネントや追加開発で安心してメソッドを再利用できるようになります。
一方でテストコードを作成する工数が発生する、テストコードのメンテナンスが必要など、デメリットもあるため、どこまでテストで保護するべきかは開発の規模や工数に応じて変化してきます。
テストコードを作成することで、テスト対象がどのような入力に対してどのような出力を返すのか、またテストでどこまで保護できているかが一目で分かるようになります。これは複雑で大規模なアプリケーションにおいて特に効果的で、当事者以外のメンバーが見たり、引き継いだりする場合もより少ない工数でキャッチアップすることができます。
追加開発でよく発生する問題として「既存のコードが追加開発により壊れてしまった(壊れたことに気づかないままリリースしてしまった)」ようなケースが挙げられると思います。テストで保護しておくことで、ソースコードを変更したことでバグが混入されてしまったかどうか迅速に確認できるので、安心して追加開発を行うことができます。
ここまでの内容でテストコードを作成いただくモチベーションを少しでもお持ちいただけたかと思います。ここから、CAPアプリケーションにおけるテストの書き方についてご紹介してまいります。
実際にテストコードを書いていただくため、テスト対象となるアプリケーションを作成します。SAP Business Application StudioでDev spaceを作成するか、VSCodeでワーキングディレクトリを作成し、cds initコマンドやCAPプロジェクト作成ウィザードを利用してプロジェクトを作成してください。
作成後、以下のコードを既存のコードに上書き、もしくは新規でファイルおよびディレクトリを作成してください。
./db/data-model.cds
using { Currency, managed, sap } from '@sap/cds/common';
namespace sap.capire.bookshop;
entity Books : managed {
key ID : Integer;
title : localized String(111) @mandatory;
stock : Integer;
price : Decimal;
}
./db/data/sap.capire.bookshop-Books.csv
ID,title,stock,price
201,Wuthering Heights,12,11.11
207,Jane Eyre,11,12.34
251,The Raven,333,13.13
252,Eleonora,555,14
271,Catweazle,22,150
./srv/cat-service.cds
using { sap.capire.bookshop as my } from '../db/data-model';
service CatalogService @(path:'/browse') {
@readonly entity Books as projection on my.Books { * } excluding { createdBy, modifiedBy };
action submitOrder ( id: Integer, quantity: Integer ) returns { stock: Integer };
}
./srv/cat-service.js
const catServiceHandlers = require(`./handlers/cat-service-handlers`)
module.exports = class CatalogService extends cds.ApplicationService {
init() {
// Reduce stock of ordered books if available stock suffices
this.on('submitOrder', catServiceHandlers.generateSubmitOrder())
// Delegate requests to the underlying generic service
return super.init()
}
}
./srv/handlers/cat-service-handlers.js
exports.generateSubmitOrder = function () {
return async (req) => {
const { Books } = cds.entities('sap.capire.bookshop')
let { id, quantity } = req.data
let book = await SELECT.from(Books, id, b => b.stock)
// Validate input data
if (!book) throw new Error(`Book #${id} doesn't exist`)
if (quantity < 1) throw new Error(`quantity has to be 1 or more`)
if (quantity > book.stock) throw new Error(`${quantity} exceeds stock for book #${id}`)
// Reduce stock in database and return updated stock value
await UPDATE(Books, id).with({ stock: book.stock -= quantity })
return book
}
}
./package.json
{
"name": "test_demo",
"version": "1.0.0",
"description": "A simple CAP project.",
"repository": "<Add your repository here>",
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@sap/cds": "^7",
"@sap/cds-hana": "^2",
"express": "^4"
},
"devDependencies": {
"@cap-js/sqlite": "^1",
"@sap/cds-dk": "^7",
"axios": "^1.6.3",
"chai": "^4.3.10",
"chai-as-promised": "^7.1.1",
"chai-subset": "^1.6.0",
"expect": "^29.7.0",
"jest": "^29.7.0"
},
"scripts": {
"start": "cds-serve",
"test": "jest"
},
"cds": {
"requires": {
"db": "sql"
}
}
}
このサンプルアプリケーションはBooks
という本の在庫情報を管理するテーブルを読み取り専用で公開し、submitOrder()
という注文用のActionが用意されているデモのアプリケーションになります。submitOrder()
を実行すると、パラメータのバリデーションが行われた後にBooks
テーブルのstock
を注文数の分だけ減らします。
見ていただくとお分かりいただけるかと思いますが、ビジネスロジックは./srv/handlers/cat-service-handlers.js
に集約されています。そのため、単体テストで守るべき対象は./srv/handlers/cat-service-handlers.js
の中のメソッドになります。実際にアプリケーションが実行されると./srv/cat-service.js
に記述したイベントハンドラがコールされますが、このイベントハンドラはテストからコールすることができないため、ハンドラとロジックを分離しています。このように「テストで守りたい対象をテストしやすい形で分離する」ようにソースコードを記述すると後からテストがしやすくなります。「テストがしやすい形」にするためには他にも以下のようなことに意識するとなお良いです。
自動テストではテスト対象のメソッドに対してパラメータを渡して、その返り値が想定した値になるかどうか?で合否を判定します。そのためメソッドの内部でグローバル変数の値を更新し、voidを返すような副作用のあるメソッドは非常にテストがしづらいです。メソッドにはパラメータと返り値を持たせ、なるべく副作用がないように設計しましょう。
どうしても副作用のあるメソッドになってしまう場合は上記のデモのようにロジックを分離してみてください。もしテスト対象が大きなメソッドである場合、複数の小さなメソッドに分離することも有効です。それらのメソッドを「テストで保護された再利用可能な部品」として扱うことができ、他のコンポーネントの開発や追加開発で非常に役に立ちます。
前述の通り自動テストでは外部システムへ接続させたくないので、その部分の処理を分離し、テスト実行時はモック化させることで外部システム接続処理を実行せずにテストを実行できます。今回は接続先がDBのみで、テスト実行時はCAPフレームワークがテスト用にインメモリDBを用意してくれるので特に接続処理を分離せずに./srv/handlers/cat-service-handlers.js
の中に記述しています。
モック化については詳細は本ブログでは割愛しますが、rewireなどのライブラリをご利用できます。
では、上記のデモアプリケーションに対してテスト用コードを作成していきましょう。
下記のディレクトリおよびファイルを作成してコードを記述してください。
./test/cat-service-handlers.unit.test.js
const cds = require('@sap/cds/lib');
const catServiceHandlers = require(`../srv/handlers/cat-service-handlers`)
describe('Unit test for submitOrder()', () => {
const { expect } = cds.test(__dirname + '/..');
const submitOrder = catServiceHandlers.generateSubmitOrder()
it('normal case', async () => {
const req = {
"data": {
"id": 251,
"quantity": 10
}
}
const orderedBook = await submitOrder(req)
expect(orderedBook.stock).to.equal(323);
})
it('error case: non existing ID', async () => {
const reqWithNonExistingId = {
"data": {
"id": 999,
"quantity": 10
}
}
await expect(submitOrder(reqWithNonExistingId)).to.be.rejectedWith(`Book #999 doesn't exist`);
})
})
./srv/handlers/cat-service-handlers.js
のgenerateSubmitOrder()
メソッドに対する単体テストになります。正常系と異常系の2つのテストケースを用意しています。書き方はいたってシンプルで、正常系のケースを見るとまずはメソッドに渡すパラメータを用意し、それをテスト対象のメソッドに渡します。最後にメソッドから返ってきた結果をexpect()
メソッドでチェックします。テスト実行時は./db/data/sap.capire.bookshop-Books.csv
の初期データがインメモリDBに用意されるので、この正常系のテストを実行するとID: 251のBookからstockが10引かれるのでその結果は323となり、このテストは成功します。(テストコードの323
を別の値に変えるとテストが失敗するようになるのでぜひ試してみてください。)
一方で異常系のケースでは例外がスローされることを期待します。存在しないBook IDをパラメータに渡すと、
if (!book) throw new Error(`Book #${id} doesn't exist`)
このセクションで例外がスローされるはずです。テストコードで例外発生を確認するには、
await expect(submitOrder(reqWithNonExistingId)).to.be.rejectedWith(`Book #999 doesn't exist`);
のように`.to.be.rejectedWith()`のパラメータにエラーメッセージを記述します。
今回はこの2つのテストケースしか作成していませんが、他の異常ケース(quantityが1以下のケース)やエッジケース(quantityとstockが同数のケース)、パラメータにNullを渡すケース、など色々なテストケースを作成してみてください。これくらいの小規模なアプリケーションだとあまりメリットが感じられないですが、これが(ソースコードを読み解くのも一苦労な)大規模なアプリケーションになるとメリットが効いてきます。テストコードを見るだけでその部分の挙動が把握でき、どこまで保証されているかが一目で確認できます。
下記のファイルを作成してコードを記述してください。
./test/submitOrder.integration.test.js
const cds = require('@sap/cds/lib');
describe('integration test for /excel-upload/checkExcel', () => {
const { POST, expect } = cds.test(__dirname + '/..');
it('normal case', async () => {
const result = await POST(`/browse/submitOrder`, { id: 251, quantity:10 });
expect(result.data.stock).to.equal(323);
});
it('error case: non existing ID', async () => {
await expect(POST(`/browse/submitOrder`, { id: 999, quantity:10 })).to.be.rejectedWith(`Book #999 doesn't exist`);
});
})
先ほどの単体テストと同様、正常系と異常系の2つのテストケースを用意しています。一見単体テストと同じことをしているように見えますが、内部結合テストでは起動したアプリケーションに対してAPIをコールして、そのレスポンスを確認している、というのが単体テストと異なるポイントになります。
submitOrder()
はActionなのでPOSTメソッドでコールします。テストコード内ではPOST()
メソッドでAPIをコールします。第一パラメータにサービスパス、第二パラメータにリクエストボディをセットしてください。結果のチェックは単体テストと同じくexpect()
メソッドで実行できます。
今回は小規模なデモアプリケーションなので本来ならここまで単体テストや結合テストで保護する必要はない(結合テストレベルで十分保護し切れる)のですが、複雑な大規模アプリケーションになると要所要所を単体テストで保護するメリットが効いてきます。
最後にテストを実行してみます。あらかじめpackage.jsonにスクリプトを定義しているので、npm test
コマンドを実行するだけでテストが実行されます。
もう少し解説すると、テスト実行にはjestというフレームワークを利用しています。jest
コマンドを実行するとテストコードを自動で検知して(拡張子がtest.jsのものを)実行してくれます。package.json
に下記のようにDependencyを追加し、スクリプトを定義しておくことでコマンド1つで実行できるようにしています。
"devDependencies": {
...
"jest": "^29.7.0"
},
"scripts": {
"start": "cds-serve",
"test": "jest"
},
本ブログではNode.js版のSAP CAPアプリケーションでのテストコードの必要性、書き方についてご紹介しました。より本格的にテストを書いていく場合はテストしたくない部分のモック化や認証認可のテスト、プロファイルの切り替えなど考えるべきことは多くあります。また本ブログでは割愛しましたが、自動化したテスト実行をCI/CDパイプラインに組み込むことも可能です。「テストに成功しないコードはデプロイ/リリースさせない」といったように強固なルールを仕組みとして実現することができます。今回割愛した部分については次回のブログでご紹介できればと考えております。
ぜひテストを書いて、快適なクラウドアプリケーション開発を!