株式会社メドレーで QA エンジニアをしている @Daishu です!
弊社の E2E テストは QA チームが実装しており、MagicPod というツールを採用しています。
私の担当する CLINICSカルテ では MagicPod でほとんどの機能の E2E テストを実装できたものの、以前から下記の課題がありました:
- 「E2E テストツール単独では、自動化できない機能がある」
- 「開発チームに自動化に必要なツール作成等を依頼したいが、対応優先度は低い」
- 「自分で何とかしたいけど、技術的なハードルが高い」
特に困っていたのがローカルファイル操作が必要な機能のテスト自動化でした。ブラウザ操作だけでは完結せず、PC 上のファイル操作が必要な機能は、既存の E2E テストツールだけでは自動化が困難でした。
このような状況で悩んでいたのですが、Cursor × Claude Code による vibe coding で、AI の力を借りながら技術課題に挑戦することで、この問題を完全解決できたので、ご紹介します。
TL;DR
- 課題: 院内ファイル連携システムの E2E テスト自動化が困難
- 挑戦: QA エンジニアが vibe coding で技術課題に挑戦
- 解決策: GitHub Actions Self-hosted Runner + Playwright (社内 PC を利用)
- 結果: 手動テスト不要でデイリー実行可能、開発工数の削減
- 手法: Cursor × Claude Code による AI ペアプログラミング
- 運用コスト: 0 円(既存インフラ活用)
院内機器のファイル連携システム は、医療機関のネットワーク内に配置された「院内機器と CLINICSカルテ を繋ぐ」システムです。院内の PC 等で常時起動して特定のディレクトリを監視、院内機器が出力したファイルを自動で CLINICSカルテ に送信、処理結果をディレクトリに出力します。
E2E テストでは、院内ファイル連携が入出力するファイル操作が必要なため、MagicPod 単独では自動化が難しい状況でした。
一般的な E2E テストツールの制約:
操作種別 | 対応可否 | 詳細 |
---|---|---|
ブラウザ操作 | ○ | ページ遷移、フォーム入力、ボタンクリック |
ファイル操作 | ○ | アップロード・ダウンロード(ブラウザ経由) |
ローカルディレクトリ | × | 任意のディレクトリへの直接ファイル配置 |
ファイル監視 | × | フォルダ監視システムとの直接連携 |
院内ファイル連携を起点とする機能は多数あり、将来を見据えても自動化は必須でした。そのため、当初は開発エンジニアの方に必要なツールの実装を依頼していました。
しかし、エンジニアの方がプロダクト開発以外に割ける工数も限られているという状況もあり、自力で解決できないか挑戦してみました。
結論から言うと、「E2E テストツールから GitHub Actions 経由で院内ファイル連携システムが動作する PC を操作する」 という構成で解決できました。
この解決策は Cursor × Claude Code と壁打ちして見つけることができました。
この構成の特徴は、モックサーバー不要で本番環境と同等の完全なE2Eテスト環境を実現できる点です。Self-hosted Runner により社内の Windows PC が自身の C:\target-system\
にアクセスし、Playwright の Node.js fs モジュールで実際のファイル操作を実行。既存インフラを活用した最小構成で、本番環境との差異なく開発・保守コストを削減できました。
他の解決策との比較
その他の選択肢と比較検討したものは、以下です。
解決策 | 実現性 | 本番整合 | 追加コスト | 対応期間 |
---|---|---|---|---|
API 開発 | ◯ (開発依頼) | ○ | × (開発工数) | △ (タスク調整) |
モックサーバー構築 | △ | × | △ (サーバー構築) | △ (1〜2週間) |
Self-hosted Runner + Playwright |
○ (QA主導) | ○ (実機) | ○ (既存活用) | ○ (数日で運用開始) |
RPA ツール | ○ | △ | × (ライセンス費) | △ (導入・設定) |
💡 スクリプトの実装に Playwright を選択した理由は、今後コードベースでの E2E テスト実装も見据えている点と、Node.js ベースでローカル環境での実行に適していた点があります。
解決策は見えましたが、QA エンジニアの私にとっては技術的なハードルが高い状況でした。そこで実際に vibe coding で実装に挑戦してみました。
① Cursor でファイル配置の実現
🤔 私: 「MagicPod だとローカルファイルを直接操作できない。何か方法はある?」
💡 Cursor Agent: 「Self-hosted Runner なら社内 PC で実行できますよ」
💡 Cursor Agent: 「Playwright は Node.js の fs モジュールでファイル操作可能です」
Cursor の提案と vibe coding で、ファイル配置 API はあっという間に完成!文字化けを考慮した Base64 デコードからファイル書き込みまで、期待通りに動作しました。
② Claude Code にバトンタッチ
しかし、院内ファイル連携システムが出力したファイルを読み取る部分で、Cursor だけでは限界を感じました (私のプロンプトが間違っていた可能性もある):
🤔 私: 「処理結果のファイルを読み取って、MagicPod に返す方法は?」
⚡️ Cursor Agent: 「GitHub Actions のログにファイル内容を書き出すのはどうですか」
🤔 私: 「ログに書き出せたけど、API では 404 エラーでジョブのログを取得できなかった」
⚡️ Cursor Agent: 「Organization の権限設定で API でアクセスできない状況ですね」
ここで Claude Code に相談すると:
🤔 私: 「Cursor と実装したけど、API制限でファイル内容を取得できない」
🧠 Claude Code: 「Secret Gist 方式はいかがですか?」
🧠 Claude Code: 「企業ファイアウォール制限等も回避でき、直接 API で取得可能です」
(人間と同じでクロスチェックが重要なのか。。と思い知る)
Claude Code の実践的な提案で、制約事項を考慮した効果的な解決策が見つかりました!
③ 文字化け問題を解決
実装中に日本語ファイルの文字化け問題が発生したので、Cursor に相談:
🤔 私: 「日本語ファイルが文字化けしてしまう」
⚡️ Cursor Agent: 「PowerShell で Base64 デコードしてみましょう」
🤔 私: 「OK、デバッグ実行してみるね」
🚨 セキュリティソフト(EDR)に検知され、該当 PC がネットワーク隔離 🚨
→ 👮 セキュリティ担当:「あのー、どうされました?」
→ 😭 私:「すみませんでした。これはすべて、AI がやったことで〜〜」
→ 👮 セキュリティ担当:「話は署で聞きますので..」
この問題も Claude Code に相談すると:
🤔 私: 「PowerShell のデコード処理が EDR に不審な動作として検知されてしまう」
🧠 Claude Code: 「iconv-lite + jschardet で自動エンコーディング検出はどうでしょう?」
→ ✅ 解決!Shift_JIS/UTF-8/EUC-JP すべて対応
社内のセキュリティに引っかかってしまい、ネットワーク隔離される一幕もありましたが、Cursor と Claude Code の助けにより、複雑な問題も乗り越えることができました (気をつけよう)
これまでは「開発エンジニアの方に依頼するしかない」と思っていた課題も自力で解決できる可能性があることを学びました。しかし、無闇に実装すると私がやってしまった「セキュリティソフトに検知される」ような事案に繋がる可能性もあります。可能であれば、エンジニアの方にレビュー頂きながら進めるのが大事だと思います。
別件でプロダクトに対する PR を作成した際のコメント。メドレーは優しいエンジニアの方が多い
とはいえ、気軽に質問から始めることができるので、頭の中の解決イメージを AI に当ててフィジビリティを検証するのも良いと思います。 今回は、モックサーバーではなく実機を利用する QA ならではのアプローチでフィジビリティを検証してみて、結果的に工数節約を実現できました!
QA エンジニアである私が Cursor × Claude Code を使って、ファイル連携システムを自動化した取り組みを紹介させていただきました。AI の力を借りて自力でブロッカーを解消し、issue をクローズできた時は、本当に嬉しかったです!開発エンジニアの方の工数を使わずに解決できました。
今回ご紹介した実装は、MagicPod に限らず様々な E2E テストツールで応用可能です。様々な業界のファイル連携のテスト自動化の参考になれば、大変嬉しいです!
このように弊社メドレーは、QA エンジニアでも自由に開発にコミットできる越境歓迎な文化、環境があります。気になった方は、ぜひカジュアル面談しましょう!
最後までご覧頂き、ありがとうございました!
気になった方はご覧ください
Step 1: Self-hosted Runner セットアップ
ファイル連携のシステムが動作している PC を GitHub Actions ランナーとして登録し、直接ファイル操作を可能にします。
.\config.cmd --url https://github.com/yourorg/yourrepo --token YOUR_TOKEN
.\run.cmd
Step 2: Playwright でスクリプトを作成
Node.js のファイル操作機能を使って、ターゲットシステムにファイルを配置・読み取りするスクリプトを作成します。
ファイル配置スクリプト:
tests/file-place.spec.ts
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import * as iconv from 'iconv-lite';
import { detect } from 'jschardet';
function decodeBase64Content(base64Content: string) {
const cleanedBase64 = base64Content.trim();
if (!cleanedBase64) {
throw new Error('受信したファイルデータが空です');
}
let buffer: Buffer;
try {
buffer = Buffer.from(cleanedBase64, 'base64');
} catch (error) {
throw new Error(`ファイルデータの変換に失敗: ${error}`);
}
const detected = detect(buffer);
const encoding = detected.encoding || 'utf-8';
const confidence = detected.confidence || 0;
console.log(`文字コード判定結果: ${encoding} (信頼度: ${confidence})`);
if (confidence >= 0.8) {
try {
return {
content: iconv.decode(buffer, encoding),
originalEncoding: encoding
};
} catch (error) {
console.warn(`文字コード変換失敗、別の方法を試行: ${error}`);
}
}
const fallbackEncodings = [
'shift_jis',
'utf-8',
'euc-jp',
'utf-16le'
];
for (const enc of fallbackEncodings) {
try {
const content = iconv.decode(buffer, enc);
if (content.length === 0) continue;
const corruptedChars = (content.match(/�/g) || []).length;
const corruptionRate = corruptedChars / content.length;
if (corruptionRate 0.1) {
console.log(`文字コード変換成功: ${enc}を使用`);
return { content, originalEncoding: enc };
}
} catch (error) {
continue;
}
}
return {
content: buffer.toString('utf-8'),
originalEncoding: 'utf-8'
};
}
test('ファイル配置', async () => {
const requiredEnvs = ['BASE_DIR', 'TARGET_SUB_DIR', 'FILE_NAME', 'FILE_CONTENT_BASE64'];
for (const env of requiredEnvs) {
if (!process.env[env]) {
throw new Error(`必要な設定が不足しています: ${env}`);
}
}
const baseDir = process.env.BASE_DIR!;
const targetDir = process.env.TARGET_SUB_DIR!;
const fileName = process.env.FILE_NAME!;
const base64Content = process.env.FILE_CONTENT_BASE64!;
try {
const { content, originalEncoding } = decodeBase64Content(base64Content);
const targetPath = path.join(baseDir, targetDir);
const filePath = path.join(targetPath, fileName);
if (!fs.existsSync(targetPath)) {
fs.mkdirSync(targetPath, { recursive: true });
console.log(`フォルダを作成しました: ${targetPath}`);
}
const buffer = iconv.encode(content, originalEncoding);
fs.writeFileSync(filePath, buffer);
console.log(`ファイル配置完了: ${filePath}`);
console.log(`使用文字コード: ${originalEncoding}`);
console.log(`ファイルサイズ: ${buffer.length} bytes`);
expect(fs.existsSync(filePath)).toBeTruthy();
} catch (error) {
console.error('ファイル配置でエラーが発生:', error);
throw error;
}
});
ファイル読み取りスクリプト:
tests/file-read.spec.ts
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import * as iconv from 'iconv-lite';
import { detect } from 'jschardet';
function readFileWithAutoEncoding(filePath: string) {
const buffer = fs.readFileSync(filePath);
const detected = detect(buffer);
const encoding = detected.encoding || 'utf-8';
const confidence = detected.confidence || 0;
console.log(`文字コード判定: ${encoding} (精度: ${confidence})`);
if (confidence >= 0.8) {
try {
return {
content: iconv.decode(buffer, encoding),
encoding,
confidence,
method: 'auto-detected'
};
} catch (error) {
console.warn(`文字コード変換失敗、他の方法を試行: ${error}`);
}
}
const fallbackEncodings = [
'shift_jis',
'utf-8',
'euc-jp',
'utf-16le'
];
for (const enc of fallbackEncodings) {
try {
const content = iconv.decode(buffer, enc);
if (content.length === 0) continue;
const hasInvalidChar = content.includes('\x00');
const corruptedChars = (content.match(/�/g) || []).length;
const corruptionRate = corruptedChars / content.length;
if (!hasInvalidChar && corruptionRate 0.1) {
return {
content,
encoding: enc,
confidence: 0.5,
method: 'fallback'
};
}
} catch (error) {
continue;
}
}
return {
content: buffer.toString('utf-8'),
encoding: 'utf-8',
confidence: 0,
method: 'forced'
};
}
async function waitForFile(filePath: string, timeoutMs: number = 30000): Promisevoid> {
const startTime = Date.now();
while (Date.now() - startTime timeoutMs) {
if (fs.existsSync(filePath)) {
const stats1 = fs.statSync(filePath);
await new Promise(resolve => setTimeout(resolve, 1000));
if (fs.existsSync(filePath)) {
const stats2 = fs.statSync(filePath);
if (stats1.size === stats2.size && stats1.size > 0) {
return;
}
}
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new Error(`タイムアウト: ファイルが見つかりません ${filePath}`);
}
async function createSecretGist(fileName: string, content: string, token: string) {
if (!token) {
throw new Error('GitHub認証トークンが設定されていません');
}
try {
const response = await fetch('https://api.github.com/gists', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'E2E-Test-Agent'
},
body: JSON.stringify({
public: false,
description: `E2Eテスト結果: ${fileName}`,
files: {
[fileName]: { content }
}
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gist作成失敗: ${response.status} ${response.statusText} - ${errorText}`);
}
return await response.json();
} catch (error) {
if (error instanceof TypeError) {
throw new Error(`ネットワークエラー: GitHub APIに接続できません - ${error.message}`);
}
throw error;
}
}
test('ファイル読み取り', async () => {
const requiredEnvs = ['BASE_DIR', 'TARGET_SUB_DIR', 'FILE_NAME', 'GITHUB_TOKEN'];
for (const env of requiredEnvs) {
if (!process.env[env]) {
throw new Error(`必要な設定が不足しています: ${env}`);
}
}
const baseDir = process.env.BASE_DIR!;
const targetDir = process.env.TARGET_SUB_DIR!;
const fileName = process.env.FILE_NAME!;
const githubToken = process.env.GITHUB_TOKEN!;
try {
const filePath = path.join(baseDir, targetDir, fileName);
console.log(`ファイル出現を待機中: ${filePath}`);
await waitForFile(filePath, 30000);
const result = readFileWithAutoEncoding(filePath);
console.log(`ファイル読み取り完了: ${result.content.length} 文字`);
console.log(`使用文字コード: ${result.encoding} (${result.method})`);
const gist = await createSecretGist(fileName, result.content, githubToken);
console.log('--- ファイル共有情報(E2Eツール用) ---');
console.log(`GIST_ID: ${gist.id}`);
console.log(`GIST_URL: ${gist.html_url}`);
console.log(`DELETE_URL: https://api.github.com/gists/${gist.id}`);
console.log(`FILE_NAME: ${fileName}`);
console.log(`CONTENT_SIZE: ${result.content.length}`);
console.log(`ENCODING: ${result.encoding}`);
console.log('--- 情報出力終了 ---');
expect(gist.id).toBeTruthy();
expect(gist.files[fileName]).toBeTruthy();
} catch (error) {
console.error('ファイル読み取りでエラーが発生:', error);
throw error;
}
});
Step 3: GitHub Actions workflow
E2E テストツールから API 経由で呼び出せる workflow を作成し、Self-hosted Runner でテストを実行します。
ファイル配置 workflow
.github/workflows/place-file.yml
on:
workflow_dispatch:
inputs:
BASE_DIR:
description: 'ベースディレクトリ'
required: true
default: 'C:\target-system'
TARGET_SUB_DIR:
description: '配置先サブディレクトリ'
required: true
FILE_NAME:
description: 'ファイル名'
required: true
FILE_CONTENT_BASE64:
description: 'ファイル内容 (Base64エンコード済み)'
required: true
jobs:
place-file:
runs-on: [self-hosted, windows]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: npm install
- run: npx playwright test tests/file-place.spec.ts
env:
BASE_DIR: ${{ github.event.inputs.BASE_DIR }}
TARGET_SUB_DIR: ${{ github.event.inputs.TARGET_SUB_DIR }}
FILE_NAME: ${{ github.event.inputs.FILE_NAME }}
FILE_CONTENT_BASE64: ${{ github.event.inputs.FILE_CONTENT_BASE64 }}
ファイル読み取り workflow
.github/workflows/read-file.yml
on:
workflow_dispatch:
inputs:
BASE_DIR:
description: 'ベースディレクトリ'
required: true
default: 'C:\target-system'
TARGET_SUB_DIR:
description: '読み取り元サブディレクトリ'
required: true
FILE_NAME:
description: 'ファイル名'
required: true
jobs:
read-file:
runs-on: [self-hosted, windows]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: npm install
- run: npx playwright test tests/file-read.spec.ts
env:
BASE_DIR: ${{ github.event.inputs.BASE_DIR }}
TARGET_SUB_DIR: ${{ github.event.inputs.TARGET_SUB_DIR }}
FILE_NAME: ${{ github.event.inputs.FILE_NAME }}
GITHUB_TOKEN: ${{ secrets.GIST_TOKEN }}
E2E テストツールからの使用方法
基本的な流れ
- ファイル配置 → ターゲットシステムにファイルを自動配置
- ファイル読み取り → ターゲットシステムの処理結果を自動取得
- 結果取得 → GitHub Gist から実際のファイル内容を取得
手順 | API 呼び出し | 必要なパラメーター |
---|---|---|
1. ファイル配置 | POST /actions/workflows/place-file.yml/dispatches |
BASE_DIR TARGET_SUB_DIR FILE_NAME FILE_CONTENT_BASE64
|
2. ファイル読み取り | POST /actions/workflows/read-file.yml/dispatches |
BASE_DIR TARGET_SUB_DIR FILE_NAME
|
3. 結果取得 |
GET /gists → GET /gists/{gist_id}
|
workflow 完了後、一覧取得して最新の gist_id を特定 |
📝 重要なポイント:
- 文字コード対応: ファイルは Base64 エンコードして送信(自動的に適切な文字コードで復元)
- 環境設定: 配置先のディレクトリとファイル名および、拡張子を指定
-
結果取得: ファイル読み取り workflow 完了後、Gist一覧API(
GET /gists
)で最新のGistを特定してファイル内容を取得 - セキュリティ: Secret Gist 使用で安全にファイル内容を共有、使用後は削除推奨
MagicPod で呼び出す例
私の場合、ファイル読み取りと結果取得を 1 つの共有ステップにまとめて利用しています。
Views: 0