これは CDK Advent Calendar 2021 の 2日目の記事です。
こんにちは!るね*(@lune_sta)です。
本日(2021年12月2日)、CDKv2がStableになりました!めでたいですね!!
v1からv2への変更点、移行方法については公式ドキュメントやAWS Blogの
AWS Cloud Development Kit v2 開発者プレビューのお知らせでも紹介していますので、合わせてご覧ください。
この記事のコードはGitHubにあります。CDKv2ベースです。
CDKのテストにまつわる話
CDKではアプリケーションのコードをテストするのと同じように、インフラのコードに対してテストを書けます。以前までは@aws-cdk/assert
というモジュールを使ったテストが一般的でしたが、このモジュールはTypeScript/JavaScript専用なのがネックでした。
そのため、すべての言語に対応した @aws-cdk/assertions
というモジュールが新しく開発され、こちらはCDKv1では2020年11月リリースの v1.132.0
からStableとして利用できるようになっています。もちろんCDKv2でも使用できます。
最近のバージョンではcdk init
で作成されるテストの雛形も @aws-cdk/assert
ではなく @aws-cdk/assertions
に変わっており、今後はこちらが主流になっていくと思われます。
@aws-cdk/assertions
の使い方については、AWS Blogのあらゆる言語でのCDKアプリケーションのテストで紹介していますが、この記事では実際にシンプルなコードに対してテストを書いてみて感触を確かめてみます。
テスト対象のプログラム
今回は、下記のようなシンプルなコードに対してテストする例を考えてみます。
const db = new dynamodb.Table(this, 'Table', {
partitionKey: {
name: 'itemId',
type: dynamodb.AttributeType.STRING,
},
tableName: 'items',
removalPolicy: RemovalPolicy.DESTROY,
})
const backend = new lambda.NodejsFunction(this, 'Function', {
entry: './lambda/index.ts',
})
db.grantReadWriteData(backend)
new apigateway.LambdaRestApi(this, 'Api', {
handler: backend
})
テストの始め方
@aws-cdk/assertions
を使うには、まずテスト対象のStackのインスタンスを生成し、Template.fromStack()
を使用してCloudformationテンプレートを生成する所から始まります。
Template.fromString()
やTemplate.fromJSON()
なども用意されているので文字列やObjectからテンプレートを生成することもできます。
import { Template } from 'aws-cdk-lib/assertions'
import { App } from 'aws-cdk-lib'
import { CdkAssertionsSamplesStack } from '../lib/cdk-assertions-samples-stack'
test('Test name', () => {
const app = new App();
const stack = new CdkTestSamplesStack(app, 'CdkTestSamplesStack', {})
const template = Template.fromStack(stack)
Full Template Match
一番シンプルなアサーションとして紹介されているのが、完成形のテンプレートと比較するFull Template Matchです。
const expected = {
'Resources': {
'TableCD117FA1': {
'Type': 'AWS::DynamoDB::Table',
'Properties': {
'KeySchema': [
{
'AttributeName': 'itemId',
'KeyType': 'HASH'
}
],
(中略)
}
template.templateMatches(expected)
templateMatches()
に限らず、@aws-cdk/assertions
のマッチ系のメソッドではデフォルトで完全一致ではなく、"オブジェクトライク"な比較が行われます。そのため実際のテンプレートは期待する値の上位集合(superset)であることが許容されます。イメージとしては下記のような感じです。完全一致を行いたい場合は後述するマッチャーを使います。
// テスト対象のテンプレート
// {
// "Resources": {
// "A": {
// "Type": "Foo::Bar",
// "Properties": {...}
// },
// "B": {
// "Type": "BAZ::QUX",
// "Properties": {...}
// }
// }
// }
// これはエラーにならない
const expected = {
'Resources': {
'A': {
'Type': 'Foo::Bar',
'Properties': {...}
}
}
template.templateMatches(expected)
// これはエラーになる
const expected = {
'Resources': {
'C': {
'Type': 'QUUX::CORGE',
'Properties': {...}
}
}
template.templateMatches(expected)
実際にFull Template Matchをする際は、手作業で期待するテンプレートを管理するよりスナップショットテストとして使うことが多いと思います。スナップショットテストは簡単で、TypeScriptでJestを使用する場合は下記のように書くだけでスナップショットの保存と比較が行われます。
// スナップショットとの比較を行う
expect(template.toJSON()).toMatchSnapshot()
テスト対象のテンプレートに意図して変更を加えた場合は、以下のコマンドでスナップショットを更新できます。詳しくはSnapshot Testingをご覧ください。
$ jest --updateSnapshot
CDKから生成されるCFnテンプレートは、CDKフレームワークとCDKアプリケーションの2つのコードの影響を受けます。スナップショットテストを使うことで、CDKのバージョンが変化してもデプロイされるリソースに影響が無いことを確認できます。また、CDKアプリケーションのリファクタをする際にも有用です。
Counting Resources
resourceCountIs()
を使うと対象の種類のリソースの数を確認できます。
// Functionが1つ作られていることを確認する
template.resourceCountIs('AWS::Lambda::Function', 1)
Resource Matching & Retrieval
テンプレート全体ではなく、特定のリソースの Properties
を確認したい場合は hasResourceProperties()
を使います。
Properties
だけでなくリソース全体(UpdateReplacePolicy
やDependsOn
など)を確認する場合は hasResource()
を使います。
// Propertiesが正しいことを確認する
template.hasResourceProperties('AWS::Lambda::Function', {
'Handler': 'index.handler'
});
// Properties以外を確認したい場合はhasResource()を使う
template.hasResource('AWS::DynamoDB::Table', {
'UpdateReplacePolicy': 'Delete'
})
似たようなものに findResources()
メソッドがあり、アサーションではなくマッチするリソースのセットを取得できます。
// 生成されるIAM Roleをすべて表示する
console.log(template.findResources('AWS::IAM::Role'))
Output and Mapping sections
OutputやMappingに対しても似たようなメソッドどして hasOutput()
、findOutput()
、hasMapping()
、findMappings()
が利用できます。その場合、引数としてlogicalId
を指定しますが、*
を指定して全てを対象にすることもできます。
(logicallId
はランダムに生成されるため、一部を *
にして ApiEndpoint*
のような書き方ができると便利そうですが、できませんでした。)
const expected = {
Value: '...'
}
template.hasOutput('*', expected)
Special Matchers
hasXxx()
、findXxx()
、templateMatches()
で使用する期待する値は、今までの例のようなリテラル値以外にもマッチャーを使用できます。
Object Matchers
Match.objectLike()
を使うとテスト対象のオブジェクトがパターンの上位集合(superset)であることを確認できます。これは マッチャーを使わない場合と同じ挙動です。
Match.objectEquals()
を使うと完全一致のアサートをすることができます。
// Match.objectLike() は部分一致
template.hasResourceProperties('AWS::DynamoDB::Table', {
'ProvisionedThroughput': Match.objectLike({
'ReadCapacityUnits': 5
})
})
// Match.objectEquals() は完全一致
template.hasResourceProperties('AWS::DynamoDB::Table', {
'ProvisionedThroughput': Match.objectEquals({
'ReadCapacityUnits': 5,
'WriteCapacityUnits': 5
})
})
Presence and Absence
Match.absent()
を使うと該当する値が存在しないことを確認できます。逆に値が存在することだけを確認したい場合は Match.anyValue()
を使います。
// 存在しないことを確認する
template.hasResourceProperties('AWS::DynamoDB::Table', {
'DummyKey': Match.absent()
})
// 存在することを確認する
template.hasResourceProperties('AWS::DynamoDB::Table', {
'TableName': Match.anyValue()
})
Array Matchers
配列に対しては Match.arrayWith()
や Match.arrayEquals()
が使用できます。
今回のテンプレートでは良い例が思いつきませんでしたが、公式ドキュメントには以下のような例が紹介されていました。
// Given a template -
// {
// "Resources": {
// "MyBar": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": ["Flob", "Cat"]
// }
// }
// }
// }
// The following will NOT throw an assertion error
const expected = {
Fred: Match.arrayWith(['Flob']),
};
template.hasResourceProperties('Foo::Bar', expected);
// The following will throw an assertion error
const unexpected = Match.objectLike({
Fred: Match.arrayWith(['Wobble']),
});
template.hasResourceProperties('Foo::Bar', unexpected);
Not Matcher
Match.not()
を使うとマッチ結果を反転させることができます。
以下も公式ドキュメントの例になります。
// Given a template -
// {
// "Resources": {
// "MyBar": {
// "Type": "Foo::Bar",
// "Properties": {
// "Fred": ["Flob", "Cat"]
// }
// }
// }
// }
// The following will NOT throw an assertion error
const expected = {
Fred: Match.not(['Flob']),
};
template.hasResourceProperties('Foo::Bar', expected);
// The following will throw an assertion error
const unexpected = Match.objectLike({
Fred: Match.not(['Flob', 'Cat']),
});
template.hasResourceProperties('Foo::Bar', unexpected);
Serialized JSON
Capturing Values
Capture()
を使用するとマッチした値をキャプチャーできます。
// 値をキャプチャーするにはCapture()を使う
const capture = new Capture()
template.hasResourceProperties('AWS::Lambda::Function', {
Runtime: capture
})
console.log(capture.asString())
// nodejs14.x
テストを書いてみた感想
@aws-cdk/assertions
を使ったテストは、生成されるCFnテンプレートへのアサーションになるため、CFnを意識しないとうまくテストが書けない所が好みが分かれそうです。たとえばAspectsを使うと通常のTypeScriptのコードでStackの内容を確認することもできるので、用途によって使い分けるのがよいと思いました。Aspectsの使い方はクラスメソッドさんの記事が分かりやすいです。
マッチャーはかゆい所に手が届かない部分もあって、たとえばhasResource()
等のメソッドは単一のリソースに対してのみアサートするため、同じ種類のリソースが複数あった時
(すべてのS3のバージョニングがonになっているか?など)の簡単な書き方が分かりませんでした。@aws-cdk/assertions
はできたばかりのモジュールのため今後の更新にも期待です。
スナップショットテストは手間がほぼかからず、CDKのバージョンアップやリファクタによる意図しないデグレを防ぐことができるため有用だと思います。まずはスナップショットテストから始めるのがよいかなと思いました。