CSRFを理解するためにハンズオンで実装してみた
本記事は生成AIによるドラフト作成後、執筆者による修正を加えたものとなっております
はじめに
CSRF攻撃について、技術書や記事で読んで「なるほど、理解した」と思っていました。
しかし、実際にチームメンバーに説明してみると、「なぜブラウザがCookieを自動送信するのが問題なの?」「Same-Origin Policyがあるのになぜ防げないの?」といった質問に対して、曖昧な回答しかできませんでした。
概念的な理解だけでは、実際の攻撃手法や対策の必要性を他の人に納得してもらうのは難しいものです。そこで、CSRF攻撃を実際に再現できる環境を作って、攻撃の仕組みと対策を体験してみることにしました。
この記事では、TypeScriptとExpressを使って脆弱性のあるWebアプリケーションと攻撃サイトを実装し、実際にCSRF攻撃を成功させた後、適切な対策を施して攻撃を防ぐまでの過程を記録しています。
読み終わった後に得られること
- CSRF攻撃の技術的メカニズムを具体的に説明できる
- なぜセッション認証だけでは不十分なのかを理解できる
- CSRFトークンによる対策の仕組みと実装方法を習得できる
- 実際の開発で見落としがちなセキュリティポイントを把握できる
なお、Githubリポジトリはこちらですので、合わせて参照してみてください。
CSRF攻撃の技術的メカニズム
攻撃が成立する条件
CSRF(Cross-Site Request Forgery)攻撃は、以下の条件が揃った時に成立します
- ユーザーが標的サイトで認証済み(セッションCookieが有効)
- 攻撃者が標的サイトの操作エンドポイントを把握
- ブラウザが攻撃サイトから標的サイトへのリクエストでCookieを自動送信
- 標的サイトがリクエストの送信元を検証していない
ブラウザのCookie送信仕様
重要なのは、ブラウザがHTTPリクエストを送信する際の動作です
# 攻撃サイト(evil.com)から標的サイト(target.com)へのリクエスト POST /update-profile HTTP/1.1 Host: target.com Origin: http://evil.com Cookie: sessionid=abc123; csrftoken=xyz789 # 自動で送信される Content-Type: application/x-www-form-urlencoded email=attacker@evil.com
ブラウザは、リクエストの送信元に関係なく、対象ドメインに紐づくCookieを自動的に送信します。この仕様により、攻撃者はユーザーの認証状態を悪用できます。
Same-Origin Policyの限界
Same-Origin Policy(SOP)は、異なるオリジン間でのデータ読み取りを制限しますが、リクエストの送信自体は制限しません
// 攻撃者ができること fetch('https://target.com/api/transfer', { method: 'POST', body: JSON.stringify({amount: 1000, to: 'attacker'}), credentials: 'include' }); // リクエスト送信は成功 // 攻撃者ができないこと .then(response => response.json()) // レスポンス読み取りはSOPでブロック
この非対称性が、CSRF攻撃を可能にしている根本的な要因です。
環境構築
既存のプロジェクト構成を活用して、CSRF攻撃のデモ環境を構築します。
プロジェクト構成の確認
csrf-demo/ ├── src/ │ ├── vulnerable/ # 脆弱性のある実装 │ │ ├── index.ts # 標的サイト │ │ └── trap.ts # 攻撃サイト │ └── secure/ # 対策済み実装 │ ├── index.ts # 標的サイト(対策版) │ └── trap.ts # 攻撃サイト(同一) ├── sessions/ # セッションファイル格納 ├── package.json └── tsconfig.json
起動手順
# 脆弱性版の起動(標的サイト:3000, 攻撃サイト:3001) npm run running # 対策済み版の起動(標的サイト:3000, 攻撃サイト:3001) npm run running2
脆弱性のある実装の分析
CSRF攻撃がどのように成立するのかを理解するため、まず脆弱性のある実装を詳細に分析してみましょう。
セッション管理の実装
現在の実装を確認してみます
// src/vulnerable/index.ts (一部抜粋) app.use(session({ store: new FileStoreSession({ path: './sessions', ttl: 86400, reapInterval: 3600, }), secret: 'your-secret-key', resave: false, saveUninitialized: false, cookie: { secure: false } // HTTPS以外でも動作 })); // 認証チェックミドルウェア function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) { if (req.session.user) { next(); // セッションにユーザー情報があれば認証OK } else { res.redirect('/login'); } }
この実装自体は一般的なセッション管理の手法ですが、CSRF攻撃の文脈では不十分であることが分かります。認証チェックはセッションの存在のみを確認しており、リクエストの正当性は検証していません。
脆弱なエンドポイントの特徴
セッション管理の次に、実際の操作を行うエンドポイントを見てみましょう
// プロフィール更新処理(脆弱性あり) app.post('/update-profile', requireAuth, (req, res) => { const { email } = req.body; req.session.email = email; // セッションに保存 console.log(`${req.session.user}のメールアドレスを${email}に更新しました`); res.send(`プロフィールが更新されました。<a href="/profile">プロフィール画面に戻る</a>`); });
問題点の分析: - リクエストの送信元を検証していない - CSRFトークンなどの保護機構がない - セッション認証のみに依存している
このエンドポイントは認証されたユーザーからのリクエストであることは確認していますが、そのリクエストが本当にユーザーの意図したものなのかは判断できません。この「意図の確認」ができていないことが、CSRF攻撃の成功を許してしまいます。
攻撃サイトの実装
では、この脆弱性を突く攻撃サイトはどのような仕組みになっているでしょうか
// src/vulnerable/trap.ts (攻撃コード部分) function attack1() { console.log('攻撃1を実行します'); const form = document.createElement('form'); form.method = 'POST'; form.action = 'http://localhost:3000/update-profile'; form.style.display = 'none'; // ユーザーに見えないよう隠す const emailInput = document.createElement('input'); emailInput.type = 'hidden'; emailInput.name = 'email'; emailInput.value = 'hacker@evil.com'; // 攻撃者の指定した値 form.appendChild(emailInput); document.body.appendChild(form); form.submit(); // フォームを自動送信 }
攻撃成功の仕組み
この攻撃コードがなぜ成功するのか、ステップごとに整理してみましょう:
- ユーザーが標的サイトでログイン:セッションCookieがブラウザに保存
- 攻撃サイトにアクセス:一見無害なページに見える
- 隠れたフォーム送信:JavaScript でPOSTリクエストを生成
- Cookie自動送信:ブラウザが標的サイトのCookieを自動的に含める
- サーバーが処理実行:正規リクエストと誤認して操作を実行
実際に攻撃を実行すると、コンソールに以下のようなログが出力されます:
userのメールアドレスをhacker@evil.comに更新しました userがコメントしました: このサイトはハッキングされました!攻撃者からのメッセージです。
これで、認証済みのユーザーの意図しない操作が実行されてしまいました。次に、このような攻撃を防ぐための対策を実装していきます。
CSRF対策の実装
攻撃の仕組みを理解したところで、効果的な対策を実装してみましょう。今回は教育目的に特化した簡易実装として、理解しやすさを重視したCSRFトークンによる防御を実装します。
注意: この実装は基本的な対策のデモンストレーションです。本番環境では、トークンの暗号化、有効期限管理、フレームワークの標準機能の活用など、より堅牢な実装が必要です。これらの改善点については後述します。
CSRFトークンによる防御
// src/secure/index.ts (対策部分) import crypto from 'crypto'; // CSRFトークンを生成 function generateCSRFToken() { return crypto.randomBytes(32).toString('hex'); } // CSRFトークンをセッションに保存 app.use((req, res, next) => { if (!req.session.csrfToken) { req.session.csrfToken = generateCSRFToken(); } next(); }); // CSRFトークンを検証するミドルウェア function verifyCSRFToken(req: express.Request, res: express.Response, next: express.NextFunction) { console.log(req.session.csrfToken, req.body.csrfToken); if (req.session.csrfToken !== req.body.csrfToken) { res.status(403).send('CSRFトークンが無効です'); } else { next(); } }
フォームへのトークン埋め込み
// 対策済みのフォーム res.send(` <form action="/update-profile" method="post"> <!-- CSRFトークンを隠しフィールドに追加 --> <input type="hidden" name="csrfToken" value="${req.session.csrfToken}" /> <input type="text" name="email" placeholder="メールアドレス" required /> <button type="submit">プロフィールを更新</button> </form> `);
保護されたエンドポイント
// プロフィール更新処理(CSRFトークン検証あり) app.post('/update-profile', requireAuth, verifyCSRFToken, (req, res) => { const { email } = req.body; req.session.email = email; console.log(`${req.session.user}のメールアドレスを${email}に更新しました`); res.send(`プロフィールが更新されました。<a href="/profile">プロフィール画面に戻る</a>`); });
なぜCSRFトークンで防御できるのか
1. 攻撃者はトークンを取得できない
// 攻撃者が試みること(失敗する) const response = await fetch('http://localhost:3000/profile'); const html = await response.text(); // Same-Origin Policyでブロック const token = extractTokenFromHTML(html); // 実行されない
2. トークンの推測は現実的に不可能
crypto.randomBytes(32).toString('hex'); // 出力例: "a7f9d2e8b1c4f6e9a2d5c8b1f4e7a0d3c6b9f2e5a8d1c4f7b0e3a6d9c2f5e8b1" // 2^256の組み合わせがあり、推測は不可能
3. サーバーサイドでの厳密な検証
function verifyCSRFToken(req, res, next) { const sessionToken = req.session.csrfToken; // サーバーが保持 const requestToken = req.body.csrfToken; // リクエストに含まれる if (!sessionToken || sessionToken !== requestToken) { return res.status(403).send('CSRFトークンが無効です'); } next(); }
対策後の攻撃失敗
対策済み版で同じ攻撃を実行すると
CSRFトークンが無効です
攻撃者は正しいCSRFトークンを取得できないため、すべてのリクエストが403エラーで拒否されます。
これで基本的なCSRFトークンによる防御が機能していることが確認できました。しかし、CSRFトークン以外にも有効な対策手法が存在します。
その他の対策手法
CSRFトークンが最も一般的で確実な対策ですが、環境や要件によっては他の手法も併用または代替として使用できます。
SameSite Cookie属性
app.use(session({ // 他の設定... cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict' // CSRF攻撃を軽減 } }));
SameSite属性の比較
| 設定値 | 動作 | CSRF対策効果 |
|---|---|---|
strict |
同一サイトからのリクエストのみCookie送信 | 最高 |
lax |
同一サイト + トップレベルナビゲーション | 高 |
none |
全てのリクエストでCookie送信 | なし |
Refererヘッダーチェック
function verifyReferer(req: express.Request, res: express.Response, next: express.NextFunction) { const referer = req.get('Referer'); const allowedOrigins = ['http://localhost:3000']; if (referer && allowedOrigins.some(origin => referer.startsWith(origin))) { next(); } else { res.status(403).send('Invalid referer'); } }
制限事項
これらの対策手法を理解したところで、実際の開発現場でどのような点に注意すべきかを見ていきましょう。
今回の実装の注意点と改善点
今回のハンズオン実装を通じて、基本的なCSRF対策の仕組みは理解できましたが、今回の実装は理解を優先した簡易版です。
本格的な実装では以下の改善が必要となるでしょう。
セキュリティの向上
1. トークンの暗号化
// 現在:平文でトークンを生成 const token = crypto.randomBytes(32).toString('hex'); // 改善案:HMAC ベースのトークン生成 const hmac = crypto.createHmac('sha256', secretKey); hmac.update(sessionId + timestamp); const token = hmac.digest('hex');
2. トークンの有効期限
interface CSRFToken { value: string; createdAt: number; expiresAt: number; } function isTokenExpired(token: CSRFToken): boolean { return Date.now() > token.expiresAt; }
3. トークンのローテーション
// リクエスト処理後に新しいトークンを生成 app.post('/protected-endpoint', verifyCSRF, (req, res) => { // 処理実行 req.session.csrfToken = generateCSRFToken(); // 新しいトークンに更新 res.json({ newToken: req.session.csrfToken }); });
パフォーマンスの最適化
1. トークンキャッシュ
const tokenCache = new Map<string, string>(); function getCachedToken(sessionId: string): string | undefined { return tokenCache.get(sessionId); }
2. バッチ検証
async function verifyMultipleTokens(requests: Request[]): Promise<boolean[]> { return Promise.all(requests.map(req => verifyCSRFToken(req))); }
これらの改善点を踏まえると、今回の実装は「CSRF攻撃と対策の仕組みを理解するためのデモ」として位置づけるべきです。本番環境では、フレームワークの標準機能や専用ライブラリの活用を検討しましょう。
まとめ
実際にCSRF攻撃を再現してみることで、以下の重要なポイントを体験できました
技術的な理解が深まったポイント
実装面で学んだこと
今後の学習課題
- より高度なCSRF攻撃手法(JSON HijackingやFlash-based攻撃など)
- Content Security Policy(CSP)との組み合わせ
- マイクロサービスアーキテクチャでのCSRF対策
CSRF攻撃は一見地味ですが、ユーザーの意図しない操作を実行させる深刻な脅威です。
概念的な理解だけでなく、実際に手を動かして攻撃と対策を体験することで、セキュリティ対策の必要性と実装方法を確実に身につけていきましょう。
既存のプロジェクトをGitHubにプッシュする手順
概要
既存のプロジェクトを新しく作成したGitHubリポジトリにプッシュするまでの完全な手順を解説します。プログラミング言語やフレームワークに関係なく、どのようなプロジェクトでも適用できます。
前提条件
手順
1. Gitリポジトリの初期化
cd /path/to/your/project git init
2. .gitignoreファイルの作成
プロジェクトルートに.gitignoreファイルを作成し、不要なファイルを除外します。
一般的な.gitignoreの例:
# Dependencies node_modules/ vendor/ __pycache__/ # Build output dist/ build/ target/ *.class # Environment variables .env .env.local # Logs logs/ *.log # IDE files .vscode/ .idea/ *.swp *.swo # OS generated files .DS_Store Thumbs.db # Temporary files *.tmp *.temp
重要: プロジェクトの種類に応じて適切な.gitignoreを設定してください。広範囲な除外(例:*.json)は避け、重要な設定ファイルが除外されないように注意してください。
3. ファイルをステージングエリアに追加
git add .
4. 最初のコミットを作成
git commit -m "Initial commit: プロジェクトの説明"
5. リモートリポジトリを追加
# SSHの場合 git remote add origin git@github.com:ユーザー名/リポジトリ名.git # HTTPSの場合 git remote add origin https://github.com/ユーザー名/リポジトリ名.git
6. GitHubにプッシュ
git push -u origin main
まとめ
この手順に従えば、どのような種類のプロジェクトでも簡単にGitHubで管理できるようになります。重要なのは.gitignoreの適切な設定と、プロジェクトの特性に合わせた除外ルールの設定です。
初回の設定が完了すれば、日常的な開発作業はgit add、git commit、git pushの3つのコマンドで十分です。
『HIGH OUTPUT MANAGEMENT』から学ぶ:マネージャーの情報収集術
先日、インテル株式会社の第3代CEOであるアンディ・グローブ氏が書いた『HIGH OUTPUT MANAGEMENT』を読みました。この本は、組織の成果を最大化するためのマネジメントの心得について書かれており、特にエンジニアのような定性評価職種のマネジメントにおける勘所が詳しく解説されています。
この本は誰向けなのか
読み進める中で「ミドルマネージャー」という言葉が出てくることからも分かるように、この本はマネージャーの中でもわりと上位のマネージャー(一般的にいう部長以上など)を想定して書かれている印象を受けました。
実際、ハイブリッド組織などの組織論まで話が発展するため、ミドルマネージャーである私としては若干ついていけない場面もちらほらありました。ただし、勉強になる箇所は多々あり、ミドルマネージャーだからといって読まなくていい本では決してありません。
その中でも特に学びになったのが、アンディが1日の大半を情報収集に費やしているという事実でした。
マネージャーの業務とは何か
本書では、マネージャーの主要業務を「チームの生産性を最大化すること」と定義し、そのためにどんな活動をするか、どのようにレバレッジをかけるかなどが詳しく書かれています。
特に印象的だったのは、書籍の序盤(第二部3章)でアンディ自身のマネージャーとしての1日のスケジュールを例に、実際にどんな活動をしているかを具体的に示している点です。
そんな活動を分析すると、主にやっていることは4つに集約されます:「情報収集・情報提示・意思決定・ナッジング(突っつき)」。しかし、その中でも著者自らが「1日の大部分を情報収集に使っている」と述べているのです。
情報収集能力を向上させるには
では、どうすれば情報収集能力を向上させることができるのでしょうか。アンディは様々な方法を紹介しています。
例えば、標準化された定期的なレポートを読むこと、社外の専門家やマスコミの人との対話、内部からの顧客の苦情の分析など、多岐にわたる手法があります。
しかし興味深いことに、アンディにとって最も役立つ情報は「たまたま交わすちょっとした会話」だと言います。そしてそれは、最も価値がある一方で、新聞の見出しのように内容はおおざっぱで不完全、時には不正確でもあるというのです。
だからこそ、新聞の本文を読むような詳細な情報や、週刊誌のような完璧ともいえる深い分析情報も必要になります。大切なのは、どれか一つに依存するのではなく、情報源が互いに補完し合い、重複するようにして、情報の信頼性を確かめられるようにすることなのです。
自分の情報収集を振り返って
このことを読んで、自分の情報収集について改めて考えてみました。
一エンジニアとして情報収集といえば、テック記事やテックニュースを読んだり、技術書を読み込んだりすることばかり考えていました。仕事のことにしても、過去の議事録を読み込んだり、ドキュメントを精査したりする程度の発想しかありませんでした。
振り返ってみると、これは本書でいう「週刊誌のような情報」のみに依存していたのかもしれません。
しかし、本書を読んで今一度自分の情報収集を棚卸してみたいと強く感じました。なぜなら、エンジニアである以前にビジネスマンであり、ましてやミドルマネージャーとしてチームを管理する立場に携わる以上、チームの生産性を最大化することに責任を持ちたいと感じたからです。そのための第一歩として、本書を読んでまず自分でも始められそうだと思ったのが、この情報収集の改善だったのです。
情報収集の棚卸をしてみた
実際にどんな情報収集があるか、本書の「新聞の見出し・新聞記事・報道週刊誌」の3段階に分けて考えてみることにしました。 以下は棚卸してみた一例です。
新聞の見出しレベル(概要把握のアクション)
- チャットのメッセージ確認
- 業務日報の確認・返信
- 短時間の立ち話や廊下での情報キャッチ
- 他部署とのランチでの何気ない会話
- など
新聞記事レベル(詳細把握のアクション)
- 重要なレポート全文の読み込み
- 定期1on1ミーティング
- 会議への参加と議事録確認
- など
報道週刊誌レベル(深層分析のアクション)
- 業界専門誌・技術書の熟読
- トレンド分析のためのデータ収集
- 戦略会議や経営陣との議論
- 業界カンファレンスや研修への参加
- など
この整理によって、今まで意識していなかった「見出しレベル」や「新聞記事レベル」の情報収集の重要性に気づくことができました。
さいごに
もちろん、情報収集だけしてもアウトプットにはつながりません。そこから情報提供や意思決定につなげていくことで、初めてチームのアウトプットを最大化していくことになるでしょう。
しかし、情報収集という「やっているようで、実は意識できていなかった部分」を改めて意識することができて、この書籍を読んでとても自己認識が深まりました。同時に、マネージャーとしての成長のための具体的な行動指針も得ることができ、非常に勉強になる一冊でした。
マネジメントに携わる方、特に情報をどう活用してチームの生産性を高めるかに悩んでいる方には、ぜひ一度手に取ってほしい書籍です。
参考
エンジニアが事業に対して責任をもたないとどうなるか?
はじめに
こんにちは。最近まで新規事業の開発エンジニアとしてプロジェクトに携わっていた者です。
「やったるで!」の精神でバリバリ開発に没頭した結果、かなり痛い目を見ました。この経験が誰かの役に立てばと思い、備忘録として記事に残します。
ちなみに、この記事で言う「エンジニア」とは、事業会社で働くエンジニアをイメージしています。あくまで私の経験に基づく主観ですので、ポエムとして酒のつまみにでも読んでいただけると嬉しいです。
結論:私が犯したたった一つの過ち
まず結論から言うと、私は「プロダクトを終わらせないこと」に対する当事者意識が欠けていました。
新規事業プロジェクトで技術的な完成度ばかりに目を向け、そのプロダクトが事業としてどうすれば生き残り、成長していけるのかを考えなかった。その結果、プロジェクトは停止し、費やした時間と努力が文字通り水の泡となったのです。
失敗の構造を振り返る
1. 組織的な問題:不在だった「プロダクトの羅針盤」
私が携わった新規事業では、事業チームは営業に特化しており、エンジニアリングを理解する人がいませんでした。そのため、開発チーム内でPM(プロジェクトマネージャー)を立てざるを得ない状況でした。
PM兼開発者となった私は、スケジュール管理のような「プロジェクトマネジメント」はしていました。しかし、「プロダクトマネジメント」の視点が完全に欠落していたのです。
- そもそも、このプロダクトは何のために存在するのか?
- 事業として継続していくために、本当に必要な機能は何か?
- 誰の、どんな課題を解決するのか?
こうした本質的な問いを立て、事業チームと対話し、プロダクトの舵を取る役割が不在でした。
2. 個人的な問題:線引きしていた「自分の仕事」
当時の私は、PM業務も未知の技術領域も初めての経験で、日々のタスクをこなすだけで手一杯でした。視野は狭まり、事業のことを考える余裕も、意識もありませんでした。
上司に「プロダクトオーナー的な立場が不在で、責任の所在が不明です」と訴えることはありました。しかし、返ってくるのは「そうだよね、検討しなきゃね」という言葉だけ。私も「報告はした」と、それ以上踏み込むことをしませんでした。
正直に告白すると、心のどこかで「誰かがやってくれるだろう」「最終的な売上責任は僕の仕事じゃない」と線を引いていたのです。
転機となった、同僚からの痛い一言
そんな私にとって転機となったのが、他のチームのリーダー(経験豊富なエンジニア)から投げかけられた、この一言でした。
「今君がやってるその開発って、本当に売れると思ってるの?」
何も考えていなかった私は、言葉に詰まりました。その場では正直、腹が立ちました。しかし今振り返れば、これこそが「なぜやるのか?」という、私が目を背けていた最も重要な問いだったのです。
その後の1年間
この一言をきっかけに、私は約1年間、事業側に問い続けました。「このプロダクトは、本当にマーケットに受け入れられるんでしたっけ?」と。
事業側からは「確かに…」という反応はあっても、具体的な方針は決まらない。ピボットもできず、時間だけが過ぎていきました。それでも私は、問い続けることしかできませんでした。
悲しい結末と、たった一つの気づき
そんな中、会社のフェーズ変化に伴い、新規事業へのリソース投下が困難になりました。「何のためにやるのか」という未来が見えないプロダクトから、事業側のキーマンが次々と去っていき、最終的にプロジェクトは停止しました。
「自分の頑張った時間、あの努力は一体何だったんだろう」
この強烈な虚しさの中で、私は一つの重要な事実に気づきました。
エンジニアは、コードや機能という「What(何を)」や「How(どう作るか)」に集中しがちです。しかし、それが「Why(なぜ作るのか)」に結びつかなければ、全ての努力は意味をなさなくなる。
言い換えれば、エンジニアには「プロダクトを終わらせないこと」に責任を持つ姿勢が必要だ、ということです。これこそが、エンジニアが事業に対して持つべき責任なのだと、今は考えています。
なぜエンジニアも事業に責任を持つべきなのか
私たちが書くコードは、それ単体で価値を生むわけではありません。そのコードが解決する課題、届ける価値、そしてユーザーの体験。これらすべてが一体となって、初めて価値が生まれます。
技術的にどれだけ優れたものを作っても、事業として失敗すれば、自分自身の仕事の価値も失われる。逆に言えば、事業の成功にコミットすることで、自分の技術的な努力が最大限に活かされ、本質的なやりがいに繋がるのです。
【実践編】私が「変わるため」に始めたこと
「じゃあ具体的に何をしているのか?」という点についても、現在の取り組みを共有します。
時間配分とマインドセット
現在は、週40時間のうち2〜3時間を、意識的に「事業を理解するための時間」に充てています。
重要なのは、これを一人でやらず、チームメンバーと一緒に行うことです。仕事は一人ではできません。そして、「事業理解」という言葉の定義や解像度は、エンジニア間でも意外と異なります。これは対話して初めて気づいたことでした。
なので、すぐに結論を出そうとせず、半年くらいかけるつもりで、根気強くチームで取り組んでいます。
具体的な段階的アプローチ
また取り組みの全体像として、いきなり壮大な目標を立てるのではなく、ステップを踏んで進めようと考えています。
Step 1:ライトな入り口『疑問点を聞く会』 (←いまここ) まずは週に1回、チームで「エンジニアの事業貢献ってなんだっけ?」「この機能って、誰がどう嬉しいんだっけ?」「この仕様の背景にある事業的な狙いは何だろう?」といった素朴な疑問を出し合い、議論する会を設けるようにしています。
Step 2:本格的な取り組み『イベントストーミング』 チームの事業への関心が高まってきたら、次はイベントストーミングの導入を計画しています。これは、開発者だけでなく、事業側のドメインエキスパートも巻き込むワークショップです。業務プロセスの全体像を可視化し、チーム全員のコンテキストを揃えるのが狙いです。
Step 3:未来の構想『AS-IS / TO-BE分析』 事業や業務への理解が深まったら、現状(AS-IS)とあるべき姿(TO-BE)をエンジニア視点で議論したいと考えています。プロダクトが長く価値を提供し続けるために、全体最適を考慮した技術的な提案ができるチームになるのが目標です。
あなた自身への問いかけ
ここまで読んでくださった皆さんに、改めて問いたいと思います。
- あなたが今開発しているプロダクトは、誰のどんな課題を解決していますか?
- そのプロダクトが届けたい、本質的な価値は何ですか?
- そのプロダクトを「終わらせない」ために、エンジニアとしてあなたに何ができますか?
もし、これらの問いにすぐに答えられないなら、まずは週に数時間、チームで「疑問点を聞く会」から始めてみることをお勧めします。
おわりに
技術的な完成度の追求は、エンジニアとして当然持つべきプロ意識です。その楽しさは、これからも大切に育てていくべきものです。
しかし、それと同じくらい、自分の技術がどう事業に貢献し、価値を生むのかを自分ごととして捉えること。これこそが、変化の激しい時代でも仕事の意味を見失わず、長く働く上での充実感やインパクトに繋がるのだと、私は失敗から学びました。
この記事が、同じように悩む誰かの助けになれば、そして悲しい経験をするエンジニアが一人でも減ることを、心から願っています。
テストでの過剰なモック化を避ける方法
はじめに
Jestなどのテストフレームワークでテストを書くとき、外部の依存関係をモック(模倣)することがよくあります。しかし、モック化のやり方によっては「常に成功するが役に立たない」テストになってしまうことがあります。
この記事では、過剰なモック化(オーバーモッキング)の問題点と、より効果的なテスト方法について初心者向けに解説します。
問題のあるコード例
まず、問題のあるコードとテストを見てみましょう。
src/chapter3/users.ts:
import axios from 'axios' export const getNameList = async () => { const users = await getUsers() return users.map(user => user.name) } export const getUsers = async () => { const users = await axios.get('/users').then(resp => resp.data) // APIのデータから限られた項目のみ抽出している return users.map(user => ({ name: `${user.lastName} ${user.firstName}`, age: user.age, isDeleted: user.isDeleted, })) }
src/chapter3/users.test.ts (問題のあるテスト):
import * as users from './users' describe('getNameList', () => { test('すべてのユーザー名を返す', async () => { const expected = ['Bob'] // getUsersをモック化 jest.spyOn(users, 'getUsers') .mockReturnValueOnce(Promise.resolve([{ name: 'Bob' }])) await expect(users.getNameList()).resolves.toEqual(expected) }) })
何が問題なの?
このテストには大きな問題があります:getUsers 関数自体がモック化されていることです。
これにより以下のような問題が発生します:
getUsers関数の実装をテストしていない
getUsers関数内のAPIレスポンスの加工処理などがテストされません実装が変わっても気づけない
getUsers関数の実装や API の仕様が変わっても、テストは常に成功してしまいます実際の動作と異なる可能性
テストは成功するのに、実際のアプリケーションでは動かないという状況が発生しやすくなります
具体例で考えてみよう
例えば、もし API の仕様が変わって、返されるデータの形式が変わったとします。あるいは getUsers 関数が名前の形式を変更したとします(例:「lastName firstName」から「firstName lastName」へ)。
このような変更があった場合:
- 実際のアプリケーション: おそらく動かなくなる、または予期しない結果になる
- 現在のテスト: 変わらず成功し続ける(
getUsers関数がモック化されているため)
このようなテストは「常に成功する」けれど「役に立たない」テストと言えます。
改善したテストコード
より良いテスト方法は、必要最小限の部分だけをモック化することです。この場合、外部依存である axios のみをモック化し、getUsers 関数の実装はそのまま動かすようにします。
修正したテスト:
import axios from 'axios'; import * as users from './users'; // axiosライブラリ全体をモック化 jest.mock('axios'); const mockedAxios = axios as jest.Mocked<typeof axios>; describe('getNameList', () => { test('すべてのユーザー名を返す', async () => { // axiosの戻り値を設定 mockedAxios.get.mockResolvedValueOnce({ data: [ { firstName: 'Robert', lastName: 'Smith', age: 25, isDeleted: false }, { firstName: 'Jane', lastName: 'Doe', age: 30, isDeleted: true } ] }); // 期待される結果 const expected = ['Smith Robert', 'Doe Jane']; // テスト実行 await expect(users.getNameList()).resolves.toEqual(expected); // axiosが正しく呼び出されたことを検証 expect(mockedAxios.get).toHaveBeenCalledWith('/users'); }); });
改善したテストのポイント解説
1. axios そのものをモック化
jest.mock('axios'); const mockedAxios = axios as jest.Mocked<typeof axios>;
これにより、実際のHTTPリクエストは送信されず、テスト内で axios の動作を制御できます。
2. 実際のAPIレスポンスに近いデータ構造を設定
mockedAxios.get.mockResolvedValueOnce({ data: [ { firstName: 'Robert', lastName: 'Smith', age: 25, isDeleted: false }, { firstName: 'Jane', lastName: 'Doe', age: 30, isDeleted: true } ] });
実際のAPIが返すであろうデータ構造を模倣することで、getUsers 関数の実装をテストできます。
3. 実際の処理の流れをテスト
await expect(users.getNameList()).resolves.toEqual(expected);
getNameList() が呼び出された時、内部で実際の getUsers() 関数が動作し、データ変換処理が行われます。
4. APIの呼び出しも検証
expect(mockedAxios.get).toHaveBeenCalledWith('/users');
正しいURLでAPIが呼び出されたかも検証しています。
このテストが実装の変更を検出できる理由
このテストでは、もし getUsers 関数の実装が変わった場合(例えば名前の形式が「lastName firstName」から「firstName lastName」に変わった場合)、テストは失敗します。
例えば以下のような実装の変更があった場合:
// 変更された実装(名前の形式が変わった) return users.map(user => ({ name: `${user.firstName} ${user.lastName}`, // 順序が変わった age: user.age, isDeleted: user.isDeleted, }))
テストは ['Smith Robert', 'Doe Jane'] を期待していますが、新しい実装では ['Robert Smith', 'Jane Doe'] が返されるため、テストは失敗します。
これにより、実装の変更が意図したものかどうかを開発者に認識させることができます。もし変更が意図的なものであれば、テストも更新する必要があります。
Mock Service Worker を使う方法
モック化の別の方法として、Mock Service Worker (MSW) を使うアプローチもあります:
// src/mocks/handlers.js import { rest } from 'msw'; export const handlers = [ rest.get('/users', (req, res, ctx) => { return res( ctx.status(200), ctx.json([ { firstName: 'Robert', lastName: 'Smith', age: 25, isDeleted: false }, { firstName: 'Jane', lastName: 'Doe', age: 30, isDeleted: true } ]) ); }), ];
MSWはネットワークレベルでリクエストをインターセプトするため、より実際の環境に近いテストが可能になります。
まとめ:適切なモック化のガイドライン
テストでモック化を行う際のガイドラインをまとめておきましょう:
必要最小限のモック化を心がける
制御できない外部依存(API、データベース、現在時刻など)だけをモック化する実装をモックするのではなく、依存をモックする
テスト対象の関数の実装はできるだけそのまま使用する実際のデータ構造を模倣する
モックデータは実際のAPIやデータベースが返すであろう構造に近づける変換処理などの内部ロジックは実際に実行する
これにより実装の変更がテストに反映される
これらのガイドラインに従うことで、「必ず成功する無意味なテスト」ではなく、「実装の正確性を検証できる意味のあるテスト」を書くことができます。
テストは安心して実装を変更するためのセーフティネットです。実装を完全に無視した過剰なモック化は、そのセーフティネットに穴を開けてしまうことになります。適切なモック化を心がけ、より価値のあるテストを書きましょう。
JavaScript のプリミティブ型とオブジェクト型の違いをわかりやすく解説
JavaScript を学んでいると、「プリミティブ型は直接値を持つけど、オブジェクトはデータの参照を持つ」といった説明をよく目にします。
でも「参照を持つ」って具体的にどういうこと?🤔 この記事では、プリミティブ型とオブジェクト型の違いを、わかりやすい図解とコード例で解説します!
プリミティブ型とは?
プリミティブ型は JavaScript における最も基本的なデータ型 です。以下の7種類があります。
String(文字列)Number(数値)BigInt(大きな整数)Boolean(真偽値)undefined(未定義)null(空の値)Symbol(ユニークな識別子)
プリミティブ型の特徴 - 変数に直接データが格納される - コピーすると新しい独立した値が作られる
プリミティブ型のデータの保存イメージ
変数 `a` の中に 10 が直接入っている +------+ | 10 | ← a +------+
コード例
let a = 10; let b = a; // `a` の値を `b` にコピー b = 20; // `b` の値を変更 console.log(a); // 10 (a は影響を受けない) console.log(b); // 20
このように、b に a を代入しても コピーされるだけなので、a は影響を受けません。
オブジェクト型とは?
オブジェクト型には以下のようなデータが含まれます。
- Object(オブジェクト)
- Array(配列)
- Function(関数)
- Date など
オブジェクト型の特徴 - 変数にデータそのものではなく「参照(メモリのアドレス)」が格納される - コピーすると「参照(アドレス)」がコピーされるため、変更すると元のデータも影響を受ける
オブジェクト型のデータの保存イメージ
変数 `obj` の中にはオブジェクト `{ name: "Alice" }` ではなく、その「住所(参照)」が入っている
+-----------+ +---------------------+
| 0x1234 | -----> | { name: "Alice" } |
+-----------+ +---------------------+
↑ `obj` の中身(アドレス)
コード例
let obj1 = { name: "Alice" }; let obj2 = obj1; // `obj1` の参照(住所)が `obj2` にコピーされる obj2.name = "Bob"; // `obj2` を変更すると... console.log(obj1.name); // "Bob" (obj1 も影響を受ける) console.log(obj2.name); // "Bob"
なぜ obj1.name も "Bob" に変わってしまうのか?
- obj1 も obj2 も 同じメモリアドレスを参照している から!
- obj1 と obj2 は、別々の変数だけど、実際には同じ { name: "Alice" } を指している。
プリミティブ型とオブジェクト型の違いまとめ
| 特徴 | プリミティブ型 | オブジェクト型 |
|---|---|---|
| 保存される内容 | 値そのもの | 参照(メモリアドレス) |
| コピー時の動作 | 値がコピーされる(独立) | 参照がコピーされる(元のオブジェクトを共有) |
| 変更の影響 | コピー先のみ変更される | コピー元とコピー先の両方が影響を受ける |
オブジェクトをコピーして影響を受けないようにする方法
浅いコピー(シャローコピー)
let obj1 = { name: "Alice", address: { city: "Tokyo" } }; let obj2 = Object.assign({}, obj1); // 浅いコピー obj2.name = "Bob"; obj2.address.city = "Osaka"; // ネストされたオブジェクトは参照のまま console.log(obj1.name); // "Alice" (影響を受けない!) console.log(obj2.name); // "Bob" console.log(obj1.address.city); // "Osaka" (影響を受ける!)
参照先を確認する方法
オブジェクトの参照が同じかどうかを確認するには、=== を使います。
let obj1 = { name: "Alice" }; let obj2 = obj1; console.log(obj1 === obj2); // true (同じ参照を持っている) let obj3 = { ...obj1 }; // 浅いコピー console.log(obj1 === obj3); // false (新しいオブジェクト)
深いコピー(ディープコピー)
let obj1 = { name: "Alice", address: { city: "Tokyo" } }; let obj2 = JSON.parse(JSON.stringify(obj1)); // JSONを使ったディープコピー obj2.address.city = "Osaka"; console.log(obj1.address.city); // "Tokyo" (影響を受けない!) console.log(obj2.address.city); // "Osaka"
まとめ
✅ プリミティブ型は変数が直接値を持つ
✅ オブジェクト型は変数が「参照(アドレス)」を持つ
✅ オブジェクトをコピーすると、元のオブジェクトも影響を受ける
✅ 影響を受けたくない場合は「浅いコピー」または「深いコピー」を使う
✅ === を使えば、同じ参照を持っているか確認できる
この違いをしっかり理解しておくと、バグの原因を素早く特定できるようになります!🚀
CommonJS と ESM(ECMAScript Modules)の違いを理解しよう
はじめに
JavaScript でモジュール(ファイルを分割して管理する仕組み)を使う方法として、CommonJS(CJS)とESM(ECMAScript Modules)の 2 つがあります。
Node.js では長らく CommonJS が標準でしたが、ESM も現在は利用可能になっています。しかし、どちらを使うべきか、どう違うのかが分かりにくいと感じる人も多いでしょう。
本記事では、初心者向けに CommonJS と ESM の違いを分かりやすく解説し、実際のコード例を交えて理解を深めていきます。
参考:
1. CommonJS とは?
CommonJS(CJS)は、Node.js の標準的なモジュールシステムとして使用されてきた形式です。
CommonJS の特徴
require()を使ってモジュールを読み込む。module.exportsまたはexportsを使って値を外部に公開する。- 同期的に モジュールを読み込むため、ブラウザ環境には適さない(Node.js 向け)。
- 理由: CommonJS は同期的に(blocking)モジュールを読み込むため、ブラウザ環境のような非同期処理が推奨される環境では適していません。特に、ネットワークを介してモジュールを取得する際に、同期的な読み込みはパフォーマンスの大きな問題になります。
- 参考: Why JavaScript Modules are Important
CommonJS のコード例
モジュールの作成(math.js)
// math.js(CommonJS) function add(a, b) { return a + b; } function multiply(a, b) { return a * b; } // モジュールをエクスポート module.exports = { add, multiply };
モジュールの読み込みと使用(app.js)
// app.js(CommonJS) const math = require('./math'); console.log(math.add(2, 3)); // 5 console.log(math.multiply(4, 5)); // 20
4. Node.js で ESM を使う方法
Node.js で ESM を使うには、以下のいずれかの設定が必要です。
- ファイルの拡張子を
.mjsにする。 package.jsonに以下の記述を追加する。json { "type": "module" }
なぜ React のプロジェクトには package.json に "type": "module" がないのか?
React プロジェクトでは、webpack や Vite などのビルドツールが ESM の処理を行うため、package.json に "type": "module" を明示的に追加する必要がないことが多いです。これらのツールは内部的に ESM を適切に処理するよう設計されており、開発者が手動で設定しなくても動作するようになっています。
5. どちらを使うべき?
現在は ESM が推奨 されていますが、プロジェクトによって選択肢が異なります。
- Node.js のレガシープロジェクト → CommonJS を使うことが多い。
- ブラウザ環境を意識する場合 → ESM が適している。
- 新規プロジェクト → 可能な限り ESM を使用する。
なぜ ESM が推奨されているのか?
- 標準仕様: ESM は JavaScript の公式なモジュールシステムとして標準化されており、ブラウザでもそのまま動作する。
- 非同期読み込み: ESM は非同期でモジュールを読み込めるため、パフォーマンスが向上する。
- ツールチェーンのサポート: 最新の JavaScript フレームワーク(React, Vue, Next.js, Vite など)は、ESM を前提としている。
参考: - TC39: ECMAScript Modules - ES Modules vs CommonJS - Node.js Blog
6. まとめ
- CommonJS は
require()を使う同期的なモジュールシステム。 - ESM は
importを使う非同期的なモジュールシステム。 - Node.js では ESM の使用が推奨されつつあるが、CommonJS も依然として多くのプロジェクトで使用されている。
これで、CommonJS と ESM の基本的な違いが理解できたと思います。今後のプロジェクトで適切なモジュールシステムを選んで活用してみてください!
参考: