1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[NestJS]UseGuardsがクラスや関数についてるかをユニットテストで検証する

Posted at

概要

NestJSにはGuardやPipe、Interceptorといったリクエストのライフサイクルが存在し、UseGuardsなどを使用することで各イベントで実行されるデコレータを生成することが可能です。
今回はこれらのデコレータがクラス・関数についているかをユニットテストレベルで検証する方法を紹介するものになります。

ただし、あくまで「ついてるかどうか」を検証するものになります。
「実際に正しい動作をするか」を検証するには別途ユニット・インテグレーションテストが必要になってくるでしょう。

やりたいこと

Guard自体の動作はユニットテストで簡単に確認できますが、クラスや関数に想定通りのGuardが付与されているかを確認したかったです。
ModuleやApollo Serverを立ち上げれば簡単に確認できますが、それだと重いしメンテナンス性も低いのでResolverだけで完結するようなテストコードを実装したかったというのが動機になります。

このテストの実装に苦労したので備忘録もかねて書き残します。

テスト対象のコード

テスト対象のコードは以下の通りです。

  • 引数のないデコレータ

単にcanActivateを呼び出すだけのデコレータです。
UseGuardsの引数にクラスそのものを渡しています。

export const ClassDecorator = () => {
    return applyDecorators(UseGuards(ClassCanActivate));
}

class ClassCanActivate implements CanActivate {
    canActivate(context: ExecutionContext) {
        return false;
    }
}
  • 引数があるデコレータ

引数によって動作を変えることを想定したデコレータです。
その関係でクラスそのものではなくインスタンスをUseGuardsに渡しています。

export const MethodDecorator = (args: any) => {
    return applyDecorators(UseGuards(new MethodCanActivate(args)));
}

class MethodCanActivate implements CanActivate {
    constructor(private args: any) { }
    canActivate(context: ExecutionContext) {
        return false;
    }
}
  • デコレータがついたResolver

至って普通のResolverです。引数のないデコレータをクラスに、引数のあるデコレータを関数につけています。
ここではResolverを対象にしますが、Controllerでも同じようにできるはずです。

@Resolver()
@ClassDecorator()
export class TestResolver {
    @Query()
    @MethodDecorator('fuga')
    method1(terget: any) { return true }

    @Mutation()
    @MethodDecorator('piyo')
    method2(terget: any) { return true }
}

実装方法

結論から言えば以下のようになります。

import { GUARDS_METADATA } from '@nestjs/common/constants';
import { TestResolver } from './test.resolver';

describe('デコレータのテスト', () => {
  const resolver = new TestResolver();

  it('TestResolver', () => {
    const hoge = Reflect.getMetadata(GUARDS_METADATA, TestResolver)
    const fuga = Reflect.getMetadata(GUARDS_METADATA, resolver.method1)
    const piyo = Reflect.getMetadata(GUARDS_METADATA, resolver.method2)

    expect(hoge[0].name).toEqual('ClassCanActivate');

    expect(fuga[0].constructor.name).toEqual('MethodCanActivate');
    expect(fuga[0].args).toEqual('fuga');

    expect(piyo[0].constructor.name).toEqual('MethodCanActivate');
    expect(piyo[0].args).toEqual('piyo');
  });
});

GUARDS_METADATAをキーとしてReflect.getMetadataを実行して、どのデコレータがついてるか・引数に何が渡っているかを検証します。

今回はGuardの有無を知りたいのでGUARDS_METADATAをキーとしてますが、キーを変えれば他の種類のデコレータでも検証可能だと思われます。
(どの定数を使えばいいかは@nestjs/common/constantsを見ればなんとなくわかると思います)

解説

デコレータについては以下のリンクが参考になります。

ここではreflect-metadataについて補足します。

NestJSのデコレータはreflect-metadataというライブラリを使用して実装されてます。
内部実装を深く追いかけていませんが、UseGuardsなどが実行されるとmetadataというグローバルな領域に以下のようなMapクラスを生成するようです。

Map(ライフサイクルGuard, Pipeなどに対応したキー, Map(対象のクラス関数名, デコレータ))

このMap生成はデコレータがついたクラスがnewされた時点で実行されるようです。
そして、イベントを迎えたタイミングで該当のデコレータ関数が実行されるという仕組みになっています。
そのため、Resolverの関数を単に実行するだけではデコレータ関数は呼び出しすらされず、モックなどをしてもデコレータがついているかどうかは検証できないです。

Reflect.getMetadataを使うとmetadataに格納されている情報を読み取ることができ、それによって上記のユニットテストを実装しているわけです。

この記事の内容は以上になります。

奮闘の記録

ここからは上記実装に至ったまでの記録です。
日記みたいなものなので読み飛ばしてもらって大丈夫です。

そもそもResolverの関数を実行したらGuardは動かない?

最初はResolverの関数が実行されればデコレータ関数も実行されるものだと思ってました。

describe('デコレータのテスト', () => {
  const resolver = new TestResolver();

  it('TestResolver', () => {
    // ここで認証エラーになる?
    resolver.method1(target)
  });
});

これだと認証はまったく働かず、Resolverの関数は普通に正常実行されました。
Test.createTestingModuleを使ってmoduleを立ち上げてからResolverオブジェクトを生成しても結果は同じでした。
(Apollo Serverまで立ち上げてGraphQLをexecuteOperationを実行すればデコレータが実行されます)

デコレータがどこまで実行されてるか調べる

次に、Resolverの関数を実行したらデコレータはどこまで実行されるか調べてみました。
結果は以下の通りです。

export const ClassDecorator = () => {
    console.log('これは実行される')
    return applyDecorators(UseGuards(ClassCanActivate));
}

class ClassCanActivate implements CanActivate {
    canActivate(context: ExecutionContext) {
        console.log('これは実行されない')
        return false;
    }
} 

この時点で、「Resolverで最初に呼び出してるデコレータ(上記のClassDecorator)をモックすれば検証できるのでは?」と考えました。

デコレータをモックしてみる

というわけで、デコレータをモックしてテストしてみました。
このときは「関数が呼ばれた時点でapplyDecoratorsが実行されるのだろう」と考えてました。

// importの前に変数宣言しないとエラーになります(理由はイマイチわかってない...)
const mockMethodDecorator = jest.fn((args: any) => jest.fn())

import { TestResolver } from './test.resolver';

jest.mock('../decorator/method.decorator', () => ({
  MethodDecorator: mockMethodDecorator
}))

describe('デコレータのテスト', () => {
  const resolver = new TestResolver();

  it('TestResolver', () => {
    resolver.method1('fuga')
    // 関数が実行されたときに1回呼ばれるはず?
    expect(mockMethodDecorator).toHaveBeenCalledTimes(1)
    mockMethodDecorator.mockClear()

    resolver.method2('piyo')
    expect(mockMethodDecorator).toHaveBeenCalledTimes(1)
  });
});

が、これもダメ。1回実行されるだろうと思ってたところで2回実行されてました...

デバッグモードで調べたところ、Resolverクラスがnewされたタイミングですべてのデコレータが実行されているようです。
これだとどの関数にどのデコレータがついてるかわからないです。

デコレータの内部実装を調べる

とはいえ、なにかしらの方法で「どの関数に」「どのデコレータが」実行されてるかが設定されているはずです。
しょうがないので、内部実装を詳しく調べてみることにしました。
ここでreflect-metadataの存在が判明し、Reflect.getMetadataの引数を色々いじって実行結果を見てみることにしました。

というようなことを試行錯誤し、上の実装方法に辿り着きました。

ちなみに...

ts-morphを使って静的解析したほうが絶対簡単だと思います。
ただ、私の実行環境だとなぜかModuleを立ち上げるのと同じレベルで重かったので上記のような実装方法になっています。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?