🎭

Playwrightでのファイルダウンロード入門(単一ファイル・複数ファイル)

2024/03/01に公開

はじめに

本記事ではE2EテスティングフレームワークであるPlaywrightで、ファイルダウンロードを扱うための方法を紹介します。言語はTypeScript版を用います。

https://playwright.dev/

簡単なおみくじページを題材に、ファイルのダウンロードを行うことを目標とします。

ダウンロードは段階的に

  • 単一ファイル
  • 複数ファイル

を扱います。

結論

単一ファイルの場合

ドキュメントに記載の通りです。

https://playwright.dev/docs/downloads

  • page.waitForEvent('download');でダウンロードイベントを待機
  • ダウンロードイベントの発火
  • await
  • saveAsで保存
// Start waiting for download before clicking. Note no await.
const downloadPromise = page.waitForEvent('download');
await page.getByText('Download file').click();
const download = await downloadPromise;

// Wait for the download process to complete and save the downloaded file somewhere.
await download.saveAs('/path/to/save/at/' + download.suggestedFilename());

複数ファイルの場合

  • page.on('download', (download: Download) => {< Downloadオブジェクトを配列に入れておく処理>}でダウンロードイベントを待機
  • イベント発火
  • for await (const download of downloads) 内でsaveAsを行う
    const downloads: Download[] = [];
    page.on('download', (download: Download) => {
      downloads.push(download);
    });

    await triggerDownload() // ダウンロード発火イベント
    await page.waitForTimeout(500);

    for await (const download of downloads) {
      await download.saveAs('/path/to/save/at/' + download.suggestedFilename());
    }

🌳 環境

  • MacBook Air (M1, 2020)
  • pnpm: 8.14.1
  • playwright: 1.42.0

本記事のコードは下記のリポジトリで公開しています。

https://github.com/HosakaKeigo/playwright-download-sample

🔧 サンプルプロジェクトの作成

Reactでランダムにテキストファイルをダウンロードする、おみくじページを作ります。

$pnpm create vite --template react-ts

プロジェクト名はplaywright-download-sampleとしました。

$cd playwright-download-sample
$pnpm install

まず単一ダウンロードのみで、おみくじを作成します。複数ダウンロードは後で実装します。

  • 画面中央に「おみくじを引く」ボタン
  • クリックすると、「omikuji_<tiimestamp>.txt」が一つダウンロードされる。
  • .txtの内容は大吉」「吉」「中吉」「小吉」「末吉」「凶」「大凶」の7種類からランダム。
  • おみくじを1回引いたら、ボタンをdisableにし、.txtに書きこんだおみくじの結果を表示

App.tsx
import './App.css';
import OmikujiButton from './OmikujiButton';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <h1>おみくじアプリ</h1>
        <OmikujiButton />
      </header>
    </div>
  );
}

export default App;
OmikujiButton.tsx
import React, { useState } from 'react';

type OmikujiResult = '大吉' | '吉' | '中吉' | '小吉' | '末吉' | '凶' | '大凶';

const OmikujiButton: React.FC = () => {
  const [result, setResult] = useState<OmikujiResult | null>(null);

  const drawOmikuji = () => {
    const results: OmikujiResult[] = ['大吉', '吉', '中吉', '小吉', '末吉', '凶', '大凶'];
    const selectedResult = results[Math.floor(Math.random() * results.length)];
    setResult(selectedResult);
    const timestamp = new Date().toISOString();
    const filename = `omikuji_${timestamp}.txt`;
    const blob = new Blob([selectedResult], { type: 'text/plain' });
    const link = document.createElement('a');
    link.href = URL.createObjectURL(blob);
    link.download = filename;
    link.click();
  };

  return (
    <div>
      <button onClick={drawOmikuji} disabled={!!result}>
        おみくじを引く
      </button>
      {result && <p>おみくじの結果: {result}</p>}
    </div>
  );
};

export default OmikujiButton;

動作テストします。

$pnpm dev
  VITE v5.1.4  ready in 396 ms

  ➜  Local:   http://localhost:5173/

「末吉」が出ました。

🎭 Playwrightの導入

ドキュメントに従い、Playwrightを追加します。

https://playwright.dev/docs/intro#installing-playwright

$pnpm create playwright
Happy hacking! 🎭

現在のディレクトリ構成です。

playwright-download-sample/
┣ node_modules/
┣ public/
┣ src/
┣ tests/
┣ tests-examples/
┣ .eslintrc.cjs
┣ .gitignore
┣ README.md
┣ index.html
┣ package.json
┣ playwright.config.ts
┣ pnpm-lock.yaml
┣ tsconfig.json
┣ tsconfig.node.json
┗ vite.config.ts

VSCodeを使っている場合は拡張機能がテストの実行に便利です。

https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright

📝 Playwrightのコード作成〜ボタンクリックまで〜

続いて、おみくじを引くところまでコードを作成します。

まず、locatorなどを返すページオブジェクトを作成します。

tests/Omikuji.page.ts
import { Page } from '@playwright/test';

export default class OmikujiPage {
  constructor(private page: Page) { }

  async open() {
    await this.page.goto('http://localhost:5173');
  }

  async drawOmikuji() {
    const omikujiButton = this.page.getByRole("button", { name: "おみくじを引く" });
    await omikujiButton.click();
  }

  async getResult() {
    const result = await this.page.getByText(/おみくじの結果: .*/).textContent();
    return result?.replace('おみくじの結果: ', '');
  }
}

続いて、おみくじを引くだけのテストを作成します。

omikuji.spec.ts
import { test, expect } from '@playwright/test';
import OmikujiPage from './Omikuji.page';

test("おみくじを引く", async ({ page }) => {
  const omikujiPage = new OmikujiPage(page);
  await omikujiPage.open();
  await omikujiPage.drawOmikuji();
  const result = await omikujiPage.getResult();
  expect(result).toMatch(/大吉||中吉|小吉|末吉||大凶/);
});

テストを実行します。

$npx playwright test

1つのテストで3つのworkerが動いているのは、デフォルトでchromium、firefox, webkitの3つがテストされるため。これらはplaywright.config.tsで設定できます。

テストのテストとして「大吉」以外を除外します。

-  expect(result).toMatch(/大吉|吉|中吉|小吉|末吉|凶|大凶/);
+  expect(result).toMatch(/大吉/);

もう1回引くとfailが入れ替わります。

と、おみくじを引くところまでのテストが完成しました。

📁単一ファイルダウンロード

続いて、ダウンロードしたテキストの内容がブラウザに出ているものと同じが検査します。

ドキュメントに掲載されているように、

  • ダウンロードイベントの待機
  • ダウンロードイベントの発火
  • ダウンロードをawait
  • saveAsでダウンロード内容を保存

という手順です。

// Start waiting for download before clicking. Note no await.
const downloadPromise = page.waitForEvent('download');
await page.getByText('Download file').click();
const download = await downloadPromise;

// Wait for the download process to complete and save the downloaded file somewhere.
await download.saveAs('/path/to/save/at/' + download.suggestedFilename());

https://playwright.dev/docs/downloads

これに倣って、おみくじ結果を保存します。

omikuji.spec.ts
import { test, expect } from '@playwright/test';
import OmikujiPage from './Omikuji.page';
import * as path from 'path';
import * as fs from 'fs';

test.describe("おみくじを引いた後のダウンロード", () => {
  const savePath = path.join('tests', 'downloads');

  // 保存先フォルダの作成
  test.beforeAll(() => {
    try {
      fs.statSync(savePath);
    } catch {
      fs.mkdirSync(savePath);
    }
  })

  test("ブラウザに表示された結果がファイルに保存されること", async ({ page }) => {
    const omikujiPage = new OmikujiPage(page);
    await omikujiPage.open();

    const downloadPromise = page.waitForEvent('download');
    await omikujiPage.drawOmikuji(); // ここでダウンロードイベント発生
    const download = await downloadPromise;
    await download.saveAs(path.join(savePath, download.suggestedFilename()));
  })
})

結果、tests/downloads以下に各ブラウザ分、3つテキストができます。

当然ですが、できたファイルをfsなどで読み込めば、内容のテストもできます。(「おまけ」で行います)

📁📁複数ファイルダウンロード

事前準備

サンプルアプリの実装

ボタン1回のクリックでおみくじが5回引け、.txtも5個ダウンロードされるように、おみくじページをカスタムします。

新しくUltraOmikujiButton.tsxを作成します。OmikujiButtonと実装が盛大に被っていますが、記事の趣旨には関係ないので、そのままにします。

UltraOmikujiButton.tsx
import React, { useState } from 'react';

type OmikujiResult = '大吉' | '吉' | '中吉' | '小吉' | '末吉' | '凶' | '大凶';

const UltraOmikujiButton: React.FC = () => {
  const [results, setResults] = useState<OmikujiResult[]>([]);
  const [disabled, setDisabled] = useState(false);

  const drawOmikuji = () => {
    setDisabled(true);
    const tempResults: OmikujiResult[] = [];
    for (let i = 0; i < 5; i++) {
      const result = drawSingleOmikuji();
      tempResults.push(result);
      downloadResultFile(result, i + 1);
    }
    setResults(tempResults);
  };

  const drawSingleOmikuji = (): OmikujiResult => {
    const results: OmikujiResult[] = ['大吉', '吉', '中吉', '小吉', '末吉', '凶', '大凶'];
    return results[Math.floor(Math.random() * results.length)];
  };

  const downloadResultFile = (result: OmikujiResult, serial: number) => {
    const timestamp = new Date().toISOString();
    const filename = `omikuji_${timestamp}_${serial}.txt`;
    const blob = new Blob([result], { type: 'text/plain' });
    const link = document.createElement('a');
    link.href = URL.createObjectURL(blob);
    link.download = filename;
    link.click();
  };

  return (
    <div>
      <button onClick={drawOmikuji} disabled={disabled}>
        おみくじを引く
      </button>
        {results.length > 0 && <p>おみくじの結果: {results.join(', ')}</p>}
    </div>
  );
};

export default UltraOmikujiButton;

結果も表示されます。われわれの新しいUltraOmikujiButtonなら、結構な確率で「大吉」が引けます!

テスト用OmikujiPageオブジェクトの修正

ボタンが増えたので、tests/Omikuji.page.tsも修正します。

getByRole("button", { name: "おみくじを引く" })にresolveする要素が二つになったので、first/lastで区別し、drawUltraOmikujiを追加します。

  async drawOmikuji() {
    const omikujiButton = this.page.getByRole("button", { name: "おみくじを引く" }).first();
    await omikujiButton.click();
  }

  async drawUltraOmikuji() {
    const ultraOmikujiButton = this.page.getByRole("button", { name: "おみくじを引く" }).last();
    await ultraOmikujiButton.click();
  }

テストブラウザにchromiumを指定

ややこしくなるので、playwright.config.tsを編集して、テストするブラウザをchromiumのみにします。

playwright.config.ts
projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },

-    {
-      name: 'firefox',
-      use: { ...devices['Desktop Firefox'] },
-    },

-    {
-      name: 'webkit',
-      use: { ...devices['Desktop Safari'] },
-    },

テストコード作成

新しくultraOmikuji.sepc.tsを作成します。

変更は

- await omikujiPage.drawOmikuji()
+ await omikujiPage.drawUltraOmikuji()

です。

ultraOmikuji.spec.ts
import { test } from '@playwright/test';
import OmikujiPage from './Omikuji.page';
import * as path from 'path';
import * as fs from 'fs';

test.describe("おみくじを引いた後のダウンロード", () => {
  const savePath = path.join('tests', 'downloads');

  test.beforeAll(() => {
    try {
      fs.statSync(savePath);
    } catch {
      fs.mkdirSync(savePath);
    }
  })

  test("ブラウザに表示された結果がファイルに保存されること", async ({ page }) => {
    const omikujiPage = new OmikujiPage(page);
    await omikujiPage.open();

    const downloadPromise = page.waitForEvent('download');
    await omikujiPage.drawUltraOmikuji(); // メソッドを変更
    const download = await downloadPromise;
    await download.saveAs(path.join(savePath, download.suggestedFilename()));
  })
})

これを実行すると、残念ながらファイルは一つだけしかできません。

これを修正して、複数のダウンロードイベントをリッスンするように変更します。

ultraOmikuji.spec.ts
  test("ブラウザに表示された結果がファイルに保存されること", async ({ page }) => {
    const downloads: Download[] = [];
    page.on('download', (download: Download) => {
      downloads.push(download);
    });

    const omikujiPage = new OmikujiPage(page);
    await omikujiPage.open();

    await omikujiPage.drawUltraOmikuji();
    await page.waitForTimeout(500);

    for await (const download of downloads) {
      await download.saveAs(path.join(savePath, download.suggestedFilename()));
    }
  })
    page.on('download', (download: Download) => {
      downloads.push(download);
    });

でdownloadを受け取って配列に入れ、最後にまとめて処理しています。

さて、保存先フォルダをきれいにして、テストを実行してみます。

こんどは_1から_5まで、5つの.txtが生成されました。大吉はあるかな〜

おまけ:Assertionの追加〜ファイルの中身の検証〜

ファイルが取れたので、せっかくなのでファイルの中身を検証します。

fsモジュールを使って、引数のディレクトリ以下のファイルの中身を返すヘルパー関数を作成します。

ultraOmikuji.spec.ts
function readFilesInDirectory(directoryPath: string) {
  try {
    const files = fs.readdirSync(directoryPath);
    const fileContents =
      files
        .filter(file => fs.statSync(path.join(directoryPath, file)).isFile())
        .map(file => {
          const filePath = path.join(directoryPath, file);
          return fs.readFileSync(filePath, 'utf8');
        })
    return fileContents;
  } catch (error) {
    console.error("An error occurred:", error);
    return [];
  }
}

これを使って、ダウロード後にAssertionを追加します。
また、テスト実行による副作用を除くために、beforeEachでフォルダ内のファイルを削除します。

最終的なテストコードは下記です。

ultraOmikuji.spec.ts
import { test, expect, Download } from '@playwright/test';
import OmikujiPage from './Omikuji.page';
import * as path from 'path';
import * as fs from 'fs';

test.describe.configure({ mode: "serial" })

test.describe("おみくじを引いた後のダウンロード", () => {
  const savePath = path.join('tests', 'downloads');

  test.beforeAll(() => {
    try {
      fs.statSync(savePath);
    } catch {
      fs.mkdirSync(savePath);
    }
  })

  test.beforeEach(() => {
    // savePathの中身を削除
    const files = fs.readdirSync(savePath);
    for (const file of files) {
      fs.unlinkSync(path.join(savePath, file));
    }
  })

  test("ブラウザに表示された結果がファイルに保存されること", async ({ page }) => {
    const downloads: Download[] = [];
    page.on('download', (download: Download) => {
      downloads.push(download);
    });

    const omikujiPage = new OmikujiPage(page);
    await omikujiPage.open();

    await omikujiPage.drawUltraOmikuji();
    await page.waitForTimeout(500);

    for await (const download of downloads) {
      await download.saveAs(path.join(savePath, download.suggestedFilename()));
    }

    const fileContents = readFilesInDirectory(savePath);
    expect(fileContents).toHaveLength(5);

    // ブラウザに表示された結果と比較
    const result = await omikujiPage.getResult();
    expect(fileContents.join(", ")).toBe(result);

    for (const fileContent of fileContents) {
      console.log(fileContent); // 大吉出るかな...?
      expect(fileContent).toMatch(/大吉||中吉|小吉|末吉||大凶/);
    }
  })
})

function readFilesInDirectory(directoryPath: string) {
  try {
    const files = fs.readdirSync(directoryPath);
    const fileContents =
      files
        .filter(file => fs.statSync(path.join(directoryPath, file)).isFile())
        .map(file => {
          const filePath = path.join(directoryPath, file);
          return fs.readFileSync(filePath, 'utf8');
        })
    return fileContents;
  } catch (error) {
    console.error("An error occurred:", error);
    return [];
  }
}

おわりに

シンプルなおみくじを例にして、Playwrightで単一ファイル・複数ファイルのダウンロードを行ってみました。

ダウンロードができればファイル内容の検証もできるので、たとえば開発環境と本番環境(ステージング環境)で同じ内容のファイルが出力されているかの検証ができます。

複数ダウンロードは少し工夫が必要なので、どなたかの参考になれば幸いです。

参考文献

https://stackoverflow.com/questions/76519947/playwright-multiple-downloads

📚 Further Readings...

もしよろしければ...

https://zenn.dev/hosaka313/articles/6c825335b288c4

https://zenn.dev/ptna/articles/105323e046ee33

Discussion