CMSクライアントの実装にクラスを使ってみる
microCMSをはじめとしたヘッドレスCMSを利用する場合、サービスが提供しているSDKを使うことでAPI周りのコードを簡略化できます。最近はフロントエンド開発におけるクラスの活用に興味があり、CMSクライアントを関数とクラスの両方で実装してみました。
説明のため、以下のライブラリを使用しています。
- microcms-js-sdk: microCMSの提供するSDK
- Vitest: テストランナー
やりたかったこと
どう実装するかの前に、解決したい課題として内部の実装を知らずにロジックのテストがしたいという動機がありました。単に依存を注入 (Dependency Injection) できればよいので、関数とクラスのどちらでも実装できます。
この記事内では説明のため、 getPosts
という関数をテスト対象として扱おうと思います。
import { GetAllContentRequest } from "microcms-js-sdk";
import { client } from "@/lib/microcms";
import { Post } from "../types";
/**
* `blog` のコンテンツを降順で取得して、タイトルをアスタリスクで囲んだ形式で返す
**/
const getPosts = async (
params?: Omit<GetAllContentRequest, "endpoint">
) => {
const posts = await client.getAllContents<Post>({
endpoint: "blog",
...params,
queries, {
...params.queries,
orders: "-publishedAt",
},
}
});
return posts.map((post) => ({
...post,
title: `*${post.title}*`,
}));
};
export { posts };
ここで「コンテンツを昇順で取得する」という要件を確認するため、意図したパラメータでAPIを呼び出せているかテストしてみます。パラメータの確認には vi.spyOn
を使います。
import { describe, expect, test, vi } from "vitest";
import { client } from "@/lib/microcms";
import type { GetAllContentsRequest } from "microcms-js-sdk";
import { getPosts } from ".";
const apiName = "blog";
describe("getPosts", () => {
test("shoud be called with `-publishedAt` parameter", async () => {
const requestParams: GetAllParams = {
endpoint: apiName,
queries: {
orders: "-publishedAt",
},
};
const spy = vi.spyOn(client, "getAllContents");
// ^^^^^ テストが関数内の実装 `getAllContents` に依存している
await getPosts();
expect(spy).toHaveBeenCalledWith(requestParams);
});
});
監視対象となるメソッド getAllContents
をスパイするのですが、実装の詳細を見ているため、getList
など別のメソッドへ切り替えた途端にテストが失敗してしまいます。
テストを通すにはスパイするメソッドを getList
に切り替えます。しかし、本来であれば実装の詳細を変更してもテストは失敗してほしくありません。
- const spy = vi.spyOn(client, "getAllContents");
+ const spy = vi.spyOn(client, "getList");
await getPosts(requestParams);
ファクトリ関数を使った実装
getPosts
の依存関係に着目して、以下を改善してあげればよさそうです。
-
createClient
で生成したオブジェクトを直接呼び出している - (テストするために) 依存先のモジュールをどう使っているか知っておく必要がある
依存先としてインターフェースを挟むことで、getAllContents
と getList
のどちらを使っているといった実装の詳細を意識しなくて済みます。また、テストではスパイ対象となるオブジェクトを明確にしたいので、引数で渡せる様にしてみましょう。
import {
createClient,
type GetAllContentRequest,
type MicroCMSClient,
} from "microcms-js-sdk";
/** 依存先となるインターフェース **/
type CMSClient<T = unknown> = {
getContents: (
params?: Omit<GetAllContentRequest, "endpoint">
) => Promise<T[]>;
};
const defaultParams: MicroCMSClient = {
serviceDomain,
apiKey,
};
/** CMSクライアントのファクトリー関数 */
const clientFactory =
<T = unknown>(params: MicroCMSClient = defaultParams) => {
return (endpoint: string) => {
const client = createClient(params);
return {
getContents: (params?: Omit<GetAllContentRequest, "endpoint">) =>
client.getAllContents<T>({ endpoint, ...params }),
} satisfies CMSClient;
};
};
export { clientFactory };
import {
createClient,
type GetAllContentRequest,
} from "microcms-js-sdk";
type CreateClientReturn = ReturnType<typeof createClient>;
type GetAllParams = Omit<GetAllContentRequest, "endpoint">;
export type { CreateClientReturn, GetAllParams };
import {
clientFactory,
type CMSClient,
type GetAllParams,
} from "@/lib/microcms";
import { Post } from "../types";
const blogClient = clientFactory<Post>()("blog");
const getPosts = async (
params?: GetAllParams,
client?: CMSClient<Post>
// ^^^^^ 引数でCMSクライアントを受け取れる
) => {
const _client = client ?? blogClient;
const posts = await _client.getContents({
// ^^^^^ microcms-js-sdkの実装に直接依存していない
...params,
});
return posts.map((post) => ({
...post,
title: `*${post.title}*`,
}));
};
export { getPosts };
- const spy = vi.spyOn(client, "getAllContents");
+ const mockClient = clientFactory({ serviceDomain, apiKey })<Post>(apiName);
+ const spy = vi.spyOn(mockClient, "getContents");
+ // ^^^^^ テスト対象の関数に渡すモックオブジェクトをスパイすればよい
- await getPosts();
+ await getPosts({}, mockClient);
expect(spy).toHaveBeenCalledWith({
...requestParams,
});
});
クラスを使った実装
(おまけみたいになってしまいましたが...) 最後にクラスを使った場合のコードを載せます。クラスと継承の本質ではないと思いますが、プログラミングで繰り返し登場するコードを減らすには有効だと思いました。今回の様に「親と子」の関係で表現できるのであれば、不要な複雑さも生じにくいはずです。
import { createClient } from "microcms-js-sdk";
import { type GetAllParams } from "./types";
/** 依存先となるインターフェース **/
type CMSClient<T = unknown> = {
getContents: (
params?: Omit<GetAllContentRequest, "endpoint">
) => Promise<T[]>;
};
/** CMSクライアントのベースクラス **/
class CMSClient<T> {
protected client: CMSClientInterface<T>;
constructor(endpoint: string) {
const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN;
const apiKey = process.env.MICROCMS_API_KEY;
if (!serviceDomain) {
throw new Error("Missing env.MICROCMS_SERVICE_DOMAIN");
}
if (!apiKey) {
throw new Error("Missing env.MICROCMS_API_KEY");
}
const params = { serviceDomain, apiKey };
const client = createClient(params);
this.client = {
getContents: (params?: GetAllParams) =>
client.getAllContents({ endpoint, ...params }),
};
}
}
export { CMSClient };
import { CMSClient, GetAllParams } from "@/lib/microcms";
import type { Post } from "../types";
/** `blog` を扱うCMSクライアントのクラス **/
import { CMSClient, GetAllParams } from "@/lib/microcms";
import type { Post } from "../types";
class BlogClient extends CMSClient<Post> {
constructor() {
super("blog");
}
getPosts = async (params?: GetAllParams) => {
const posts = await this.client.getContents(params);
return posts.map((post) => ({
...post,
title: `*${post.title}*`,
}));
};
}
export { BlogClient };
test("can be called with queries", async () => {
+ const blogClient = new BlogClient();
const requestParams: GetAllParams = {
queries: {
orders: "-publishedAt",
},
};
- const mockClient = clientFactory({ serviceDomain, apiKey })<Post>(apiName);
- const spy = vi.spyOn(mockClient, "getContents");
+ const spy = vi.spyOn(blogClient, "getPosts");
おわりに
今回はテストをきっかけにして、CMSクライアントの実装について考えてみました。関数・クラスともに比較できるほど理解を落とし込めなかったので、適切な技術選択ができる様にこれからも勉強していこうと思います。
Discussion