วิธีเขียน test ให้ไฟล์ Excel ที่ export จาก Laravel

ใช้ Excel::fake() เช็กว่าปุ่มเรียก export ถูกตัวและส่งค่าที่ผู้ใช้เลือกไปครบ แล้วสร้างไฟล์จริงด้วย Excel::raw() เปิดกลับด้วย PhpSpreadsheet เพื่อตรวจค่า ชนิดของเซลล์ format และ merge ก่อนเปิดใน Excel เป็นด่านสุดท้าย

เผยแพร่
เวลาอ่าน
4 นาที
ในโพสต์นี้ 8 หัวข้อ
  1. สองเรื่องที่ต้อง test แยกกัน
  2. Excel::fake() เช็กว่าปุ่มเรียก export ถูกตัว
  3. ค่าที่ผู้ใช้เลือกต้องไปถึง query จริง
  4. สร้างไฟล์จริงแล้วเปิดกลับด้วย PhpSpreadsheet
  5. merge และสิ่งที่อ่านจาก XML ได้ตรงกว่า
  6. export แบบ streaming เขียนลงไฟล์ชั่วคราวแล้วอ่านกลับ
  7. ปุ่ม download ใน Livewire หรือ Filament
  8. สุดท้ายยังต้องเปิดใน Excel

ไฟล์ Excel เป็นของที่ผู้ใช้เห็นทุกวัน แต่มักเป็นส่วนที่ test น้อยที่สุดในระบบ เพราะดูเหมือนต้องเปิดไฟล์ดูด้วยตา จริง ๆ แล้วเกือบทุกเรื่องที่เราอยากรู้ เขียน test ได้ ตั้งแต่ปุ่มส่งช่วงวันที่ไปถูกไหม ไปจนถึงคอลัมน์เลขบัตรยังเป็นข้อความอยู่หรือเปล่า โพสต์นี้ใช้ Pest กับ Laravel Excel เป็นหลัก ถ้ายังไม่เคยเขียน test ที่มีไฟล์ ลองดู test อัปโหลดไฟล์ด้วย Pest ก่อนก็ได้ครับ

สองเรื่องที่ต้อง test แยกกัน

เรื่อง ตัวอย่างคำถาม เครื่องมือ
การเรียก export กดปุ่มแล้วได้ export ตัวไหน ชื่อไฟล์อะไร ส่งเข้าคิวหรือดาวน์โหลดตรง ค่าจากฟอร์มไปถึงไหม Excel::fake()
ตัวไฟล์ แถวไหนมีค่าอะไร เซลล์เป็นข้อความหรือตัวเลข format ถูกไหม สีหัวตาราง merge ตรงไหน สร้างไฟล์จริงแล้วเปิดกลับ

Excel::fake() ไม่สร้างไฟล์ จึงเร็วและไม่ต้องยุ่งกับดิสก์ แต่ก็บอกอะไรเกี่ยวกับหน้าตาไฟล์ไม่ได้เลย ส่วน format กับสีตั้งใน AfterSheet หรือใน value binder ซึ่งทำงานตอนเขียนไฟล์เท่านั้น ต้องเปิดไฟล์จริงถึงจะเห็น

Excel::fake() เช็กว่าปุ่มเรียก export ถูกตัว

use App\Exports\OrdersExport;
use Maatwebsite\Excel\Facades\Excel;

it('ส่งช่วงวันที่ที่ผู้ใช้เลือกไปให้ export', function () {
    Excel::fake();
    Excel::matchByRegex();

    $this->actingAs(User::factory()->admin()->create())
        ->get(route('orders.export', ['from' => '2026-06-01', 'to' => '2026-06-30']))
        ->assertOk();

    Excel::assertDownloaded('/^ยอดขาย-.*\.xlsx$/', function (OrdersExport $export) {
        return $export->from === '2026-06-01' && $export->to === '2026-06-30';
    });
});

ชื่อไฟล์ที่มีวันที่และเวลาต่อท้ายเทียบตรง ๆ ไม่ได้ Excel::matchByRegex() ให้ส่ง regex แทนชื่อไฟล์ ส่วน callback ได้ object ของ export มาตรวจค่าที่ส่งเข้าไป ถ้าปุ่มส่งงานเข้าคิวหรือเก็บไฟล์ลงดิสก์ ใช้ Excel::assertQueued() หรือ Excel::assertStored() แทน โดยส่งชื่อ disk ไปด้วย

ค่าที่ผู้ใช้เลือกต้องไปถึง query จริง

test ด้านบนเช็กว่า export ได้ค่าไป แต่ยังไม่ได้เช็กว่า export เอาค่านั้นไปใช้ บั๊กที่เจอได้บ่อยคือปุ่มที่มีสองทาง ดาวน์โหลดเลยกับส่งเข้าอีเมล แล้วทางหนึ่งยังใช้ช่วงวันที่ที่ใครสักคนใส่ไว้ตอนทดสอบ ผู้ใช้เลือกเดือนไหนก็ได้ข้อมูลชุดเดิม test ที่กันเรื่องนี้ได้ดีคือสร้างข้อมูลทั้งในและนอกช่วง แล้วดูว่าในไฟล์มีแค่ข้อมูลในช่วง

it('ไฟล์มีเฉพาะคำสั่งซื้อในช่วงที่เลือก', function () {
    $inRange = Order::factory()->create(['confirmed_at' => '2026-06-15 10:00']);
    $outside = Order::factory()->create(['confirmed_at' => '2025-01-10 10:00']);

    $numbers = (new OrdersExport(from: '2026-06-01', to: '2026-06-30'))
        ->query()->pluck('number');

    expect($numbers)->toContain($inRange->number)
        ->not->toContain($outside->number);
});

export ที่ใช้ FromQuery เรียก query() ตรง ๆ ได้เลย ไม่ต้องสร้างไฟล์ ส่วนปุ่มใน Livewire หรือ Filament ดูท้ายโพสต์

สร้างไฟล์จริงแล้วเปิดกลับด้วย PhpSpreadsheet

Excel::raw() คืนเนื้อไฟล์เป็น string เราเขียนลงไฟล์ชั่วคราวแล้วให้ PhpSpreadsheet อ่านกลับ ทำเป็น helper ไว้ใน tests/Pest.php จะได้ใช้ทุก test (test นี้ต้องไม่เรียก Excel::fake() เพราะ raw() ของตัว fake ไม่ได้สร้างไฟล์จริง)

use Maatwebsite\Excel\Excel as ExcelWriter;
use Maatwebsite\Excel\Facades\Excel;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;

function renderExport(object $export): Spreadsheet
{
    $path = tempnam(sys_get_temp_dir(), 'export');
    file_put_contents($path, Excel::raw($export, ExcelWriter::XLSX));

    try {
        return IOFactory::createReader('Xlsx')->load($path);
    } finally {
        unlink($path);
    }
}

จากนั้นตรวจได้ทุกอย่างที่ PhpSpreadsheet อ่านออก ตัวอย่างนี้เช็กสามเรื่องที่พังบ่อยที่สุด เลขบัตรประชาชนต้องเป็นข้อความ ยอดเงินต้องมี format และหัวตารางต้องมีสีพื้น

it('เขียนเลขบัตรเป็นข้อความ และยอดเงินมีทศนิยมสองตำแหน่ง', function () {
    $sheet = renderExport(new CustomersExport([
        ['name' => 'ลูกค้า ก', 'id_card' => '1103700012345', 'balance' => 1250.5],
    ]))->getActiveSheet();

    expect($sheet->getCell('B2')->getValue())->toBe('1103700012345')
        ->and($sheet->getCell('B2')->getDataType())->toBe('s')
        ->and($sheet->getStyle('C2')->getNumberFormat()->getFormatCode())->toBe('#,##0.00')
        ->and($sheet->getStyle('A1')->getFill()->getStartColor()->getRGB())->toBe('D3D3D3');
});

getDataType() ที่ได้ 's' คือข้อความ ส่วน 'n' คือตัวเลข ถ้าเลข 13 หลักกลายเป็น 'n' ใน Excel จะแสดงเป็น 1.1037E+12 (เหตุผลอยู่ใน โพสต์เรื่องเลขยาวกับเลข 0 นำหน้า) ชีทอื่นในไฟล์เดียวกันเรียกด้วย getSheetByName('สรุป') ได้

merge และสิ่งที่อ่านจาก XML ได้ตรงกว่า

$sheet->getMergeCells() คืนรายการช่วงที่ merge ไว้ เช่น ['A1:F1' => 'A1:F1'] ใช้เช็กหัวรายงานที่ merge ข้ามคอลัมน์ได้ บางเรื่อง reader แปลงค่าให้ก่อนเราเห็น เช่น boolean หรือรายละเอียดของ style ถ้าอยากรู้ว่าในไฟล์เขียนอะไรไว้จริง ให้เปิดไฟล์ชั่วคราวที่เขียนจาก Excel::raw() เป็น zip (ก่อน unlink) แล้วอ่าน XML ของชีทตรง ๆ

$zip = new ZipArchive();
$zip->open($path);
$xml = $zip->getFromName('xl/worksheets/sheet1.xml');

expect($xml)->toContain('<mergeCell ref="A1:F1"/>')
    ->and($xml)->toMatch('/<c r="D2"[^>]* t="b"/');

t="b" คือเซลล์ boolean ใช้ตอนต้องการให้ TRUE กับ FALSE เป็นค่าจริงใน Excel ไม่ใช่ 1 กับ 0 ซึ่งบางทีเลข 0 หายไปทั้งช่อง

export แบบ streaming เขียนลงไฟล์ชั่วคราวแล้วอ่านกลับ

export ที่เขียนด้วย OpenSpout ไม่ผ่าน Excel::raw() ให้สั่ง writer เขียนลง path ชั่วคราว แล้วอ่านกลับด้วย PhpSpreadsheet เหมือนเดิม มีสองเรื่องที่ทำให้ test ล้มทั้งที่ไฟล์ถูก

  • ข้อความกลับมาเป็น RichText OpenSpout เขียนข้อความแบบ inline string พอ PhpSpreadsheet อ่านกลับ ค่าจะเป็น object RichText ให้ cast เป็น string ก่อนเทียบ
  • 2.0 กลับมาเป็น 2 ตัวเลขทศนิยมที่ลงตัวอ่านกลับมาเป็น int ใช้ toEqual() แทน toBe()
it('รายงานสต็อกเขียนรหัสสินค้าและจำนวนครบ', function () {
    $path = sys_get_temp_dir().'/stock-'.uniqid().'.xlsx';

    (new StockReportWriter)->write([['sku' => 'ABC-001', 'qty' => 2.0]], $path);

    $sheet = IOFactory::createReader('Xlsx')->load($path)->getActiveSheet();

    expect((string) $sheet->getCell('A2')->getValue())->toBe('ABC-001')
        ->and($sheet->getCell('B2')->getValue())->toEqual(2.0);

    unlink($path);
});

ถ้าไฟล์ใหญ่เกินกว่าจะให้ PhpSpreadsheet เปิด test ด้วยข้อมูลไม่กี่แถวก็พอ เพราะสิ่งที่ตรวจคือรูปแบบของแถว ไม่ใช่จำนวน

ปุ่ม download ใน Livewire หรือ Filament

ปุ่มที่เป็น action ของ Livewire ไม่ได้คืน HTTP response ให้ test ตรง ๆ Livewire เก็บไฟล์ไว้ใน effect ชื่อ download ซึ่งเราเช็กได้ด้วย assertFileDownloaded() และถอดเนื้อไฟล์ออกมาดูได้

it('ปุ่ม Export ดาวน์โหลดเฉพาะช่วงวันที่ที่เลือก', function () {
    $inRange = Order::factory()->create(['confirmed_at' => '2026-06-15 10:00']);
    $outside = Order::factory()->create(['confirmed_at' => '2025-01-10 10:00']);

    $component = Livewire::test(ListOrders::class)
        ->callAction('export', data: [
            'from' => '2026-06-01',
            'to' => '2026-06-30',
            'send_by_email' => false,
        ])
        ->assertFileDownloaded();

    $csv = base64_decode(data_get($component->effects, 'download.content'));

    expect($csv)->toContain($inRange->number)
        ->not->toContain($outside->number);
});

ตัวอย่างนี้เป็น CSV จึงค้นข้อความในเนื้อไฟล์ได้เลย ถ้าเป็น XLSX ให้เขียน $csv ลงไฟล์ชั่วคราวแล้วเปิดด้วย renderExport แบบด้านบน test ตัวนี้จับบั๊กปุ่มสองทางที่ใช้ค่าไม่ตรงกันได้ตรงที่สุด เพราะเดินผ่านฟอร์มจริง

สุดท้ายยังต้องเปิดใน Excel

test ทุกตัวข้างบนอ่านไฟล์ด้วย library ซึ่งใจกว้างกว่า Excel ไฟล์ที่ merge ซ้อนกัน หรือมีช่วงความกว้างคอลัมน์ที่ทับกัน PhpSpreadsheet อ่านได้ปกติ แต่ Excel จะขึ้นข้อความว่าพบปัญหาในไฟล์ แล้ว "ซ่อม" ด้วยการลบ merge ทั้งชีททิ้ง เมื่อแก้ layout ของรายงาน ให้เปิดไฟล์จาก test หรือจากเครื่อง dev ใน Excel จริงหนึ่งรอบก่อนส่ง ถ้าผู้ใช้เปิดด้วย Numbers หรือ Google Sheets ด้วยก็เปิดดูในนั้นอีกรอบ

จบซีรีส์ Export Excel และ CSV จาก Laravel แล้ว

ดูทั้ง 12 ตอน →

อ่านต่อ

ล่าสุด

ดูทั้งหมด →