Test OTP SMS trong Laravel: Http::fake, Notification::fake và smoke test với số thật
Mình làm việc cùng HeroSMS, dịch vụ số ảo dùng ở phần smoke test. Bài viết có dùng công cụ AI; code đã được đối chiếu với tài liệu API HeroSMS và docs Laravel 13.
Test OTP SMS trong Laravel thường chỉ dừng ở feature test: gọi Notification::fake(), assert vài dòng, PR xanh là merge. Rồi sau một lần release, user báo không nhận được mã. Lỗi hiếm khi nằm ở logic sinh mã. Thường là ai đó sửa template SMS, key của SMS gateway hết hạn hoặc bị thu hồi, hoặc config/services.php đọc nhầm tên biến môi trường. Fake không bắt được mấy lỗi này, vì khi dùng fake thì SMS chưa bao giờ rời khỏi process PHP.
Bài này dựng hai tầng test cho luồng xác thực OTP trong Laravel. Tầng một là feature test với Notification::fake và Http::fake, chạy ở mỗi PR. Tầng hai là smoke test chạy theo lịch trên GitHub Actions: cho staging gửi OTP thật tới một số điện thoại thuê qua API, rồi verify bằng chính mã đó. Cùng cách chia này mình đã áp dụng cho test Playwright nhận OTP qua webhook trong CI/CD và Appium OTP automation trên Android; lần này tới lượt backend PHP.
Môi trường trong bài: Laravel 13, Pest 5 và PHP 8.4 (Pest 5 cần PHP 8.4, còn Laravel 13 chỉ cần 8.3). Nếu dự án còn ở Laravel 12, toàn bộ code HTTP client và notification bên dưới vẫn chạy y nguyên, bạn chỉ cần dùng Pest 4.
Test OTP trong Laravel: mock ở mỗi PR, số thật theo lịch
Mỗi cách test chứng minh một chuyện khác nhau. Hai dòng đầu là tầng một (chạy ở mỗi PR), dòng cuối là tầng hai:
| Cách | Chứng minh được gì | Chi phí | Khi nào chạy |
|---|---|---|---|
Notification::fake() |
Sinh mã, gửi đúng notification cho đúng user; verify, hết hạn, rate limit | Miễn phí, vài mili giây | Mỗi PR |
Http::fake() |
SmsChannel gọi gateway đúng URL, header, body; xử lý 500 và mất kết nối |
Miễn phí | Mỗi PR |
| Sandbox hoặc test credentials của nhà cung cấp SMS | Key và request tới gateway hợp lệ | Tùy nhà cung cấp | Mỗi PR hoặc hằng ngày |
| Số thật | SMS đi qua gateway và nhà mạng tới một số thật, staging chấp nhận đúng mã đó | Trả tiền theo số | Hằng ngày theo lịch, trước release |
Sandbox của nhà cung cấp SMS là lớp ở giữa, có hay không còn tùy gateway, nên bài này không đi sâu. Mock chạy ở mọi PR vì nhanh và rẻ. Số thật chạy theo lịch hoặc trước release. Đừng thêm cờ kiểu SKIP_OTP=true vào backend: CI xanh nhưng khâu SMS chẳng được kiểm tra, và chỉ cần lỡ bật trên production là ai cũng đăng nhập được mà không cần mã.
Luồng gửi OTP SMS mẫu trong Laravel: Notification và custom SMS channel
Để có cái cho test chạy, đây là một luồng đăng nhập bằng số điện thoại ở mức tối giản. Từ Laravel 11 không còn routes/api.php có sẵn, chạy php artisan install:api để tạo. Bảng users cần thêm cột phone (unique) và phone_verified_at; name, email, password chuyển sang nullable; thêm phone vào #[Fillable([...])] trên model User (Laravel 13) hoặc vào $fillable (Laravel 12).
Thêm hai route vào routes/api.php. Nhớ import controller: thiếu dòng use thì Laravel đi tìm class OtpController ở namespace gốc và mọi request đều trả 500.
<?php
// routes/api.php
use App\Http\Controllers\OtpController;
use Illuminate\Support\Facades\Route;
Route::post('/otp/send', [OtpController::class, 'send'])->middleware('throttle:otp');
Route::post('/otp/verify', [OtpController::class, 'verify']);
Rate limiter otp khai báo trong AppServiceProvider có sẵn:
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
// 3 lần gửi mỗi phút cho mỗi số, vượt thì trả 429
RateLimiter::for('otp', fn (Request $request) =>
Limit::perMinute(3)->by((string) $request->input('phone')));
}
<?php
// app/Http/Controllers/OtpController.php
namespace App\Http\Controllers;
use App\Models\User;
use App\Notifications\OtpCode;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
class OtpController extends Controller
{
public function send(Request $request)
{
$phone = $request->validate(['phone' => ['required', 'regex:/^\+\d{8,15}$/']])['phone'];
$code = (string) random_int(100000, 999999);
// Chỉ lưu hash, sống 5 phút
Cache::put("otp:{$phone}", Hash::make($code), now()->addMinutes(5));
User::firstOrCreate(['phone' => $phone])->notify(new OtpCode($code));
return response()->json(['sent' => true]);
}
public function verify(Request $request)
{
$data = $request->validate([
'phone' => ['required', 'string'],
'code' => ['required', 'digits:6'],
]);
$hash = Cache::get("otp:{$data['phone']}");
if (! $hash || ! Hash::check($data['code'], $hash)) {
return response()->json(['message' => 'Mã OTP sai hoặc đã hết hạn'], 422);
}
Cache::forget("otp:{$data['phone']}");
User::where('phone', $data['phone'])->update(['phone_verified_at' => now()]);
return response()->json(['verified' => true]); // app thật: tạo token hoặc session ở đây
}
}
Notification gửi qua một custom channel. via() trả thẳng tên class của channel, Laravel tự resolve:
<?php
// app/Notifications/OtpCode.php
namespace App\Notifications;
use App\Notifications\Channels\SmsChannel;
use Illuminate\Notifications\Notification;
class OtpCode extends Notification
{
public function __construct(public readonly string $code) {}
public function via(object $notifiable): array
{
return [SmsChannel::class];
}
public function toSms(object $notifiable): string
{
// Không dấu: SMS có dấu dùng mã hóa Unicode, mỗi tin chứa ít ký tự hơn
return "Ma xac thuc cua ban la {$this->code}. Ma het han sau 5 phut.";
}
}
<?php
// app/Notifications/Channels/SmsChannel.php
namespace App\Notifications\Channels;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;
class SmsChannel
{
public function send(object $notifiable, Notification $notification): void
{
$phone = $notifiable->routeNotificationFor('sms', $notification);
if (! $phone) {
return;
}
Http::baseUrl(config('services.sms.url'))
->withToken(config('services.sms.key'))
->acceptJson()
->timeout(10)
->post('/messages', ['to' => $phone, 'text' => $notification->toSms($notifiable)])
->throw();
}
}
// app/Models/User.php (thêm use Illuminate\Notifications\Notification;)
public function routeNotificationForSms(Notification $notification): ?string
{
return $this->phone;
}
// config/services.php
'sms' => ['url' => env('SMS_GATEWAY_URL'), 'key' => env('SMS_GATEWAY_KEY')],
Endpoint /messages và body to, text thuộc về một gateway giả định, bạn thay bằng API của nhà cung cấp SMS mà dự án đang dùng. Verify cũng nên có limit riêng để chống dò mã, mình lược đi cho gọn.
Feature test với Notification::fake
Notification::fake() thay dispatcher bằng bản fake: không channel nào chạy, không SMS nào được gửi, nhưng bạn vẫn assert được ai nhận notification nào. Mấu chốt là lấy mã ra từ closure của assertSentTo, rồi dùng chính mã đó gọi verify. Nếu dự án tạo bằng laravel new với Pest, bỏ comment dòng ->use(RefreshDatabase::class) trong tests/Pest.php.
<?php
// tests/Feature/OtpFlowTest.php
use App\Models\User;
use App\Notifications\Channels\SmsChannel;
use App\Notifications\OtpCode;
use Illuminate\Support\Facades\Notification;
beforeEach(function () {
Notification::fake();
$this->phone = '+84901234567';
$this->user = User::factory()->create(['phone' => $this->phone]);
});
// Mã trong notification OtpCode gần nhất đã gửi cho $user
function sentOtpCode(User $user): string
{
$code = null;
Notification::assertSentTo($user, OtpCode::class, function (OtpCode $n) use (&$code) {
$code = $n->code;
return true;
});
return $code;
}
test('gửi OTP qua SMS cho đúng user', function () {
$other = User::factory()->create(['phone' => '+84907654321']);
$this->postJson('/api/otp/send', ['phone' => $this->phone])->assertOk();
Notification::assertSentTo(
$this->user,
OtpCode::class,
fn (OtpCode $n, array $channels) => $channels === [SmsChannel::class]
&& preg_match('/^\d{6}$/', $n->code) === 1,
);
Notification::assertNotSentTo($other, OtpCode::class);
Notification::assertSentTimes(OtpCode::class, 1);
});
test('trả 422 cho mã sai và chấp nhận mã đúng', function () {
$this->postJson('/api/otp/send', ['phone' => $this->phone]);
$code = sentOtpCode($this->user);
$wrong = $code === '000000' ? '111111' : '000000';
$this->postJson('/api/otp/verify', ['phone' => $this->phone, 'code' => $wrong])
->assertUnprocessable();
$this->postJson('/api/otp/verify', ['phone' => $this->phone, 'code' => $code])
->assertOk()
->assertJson(['verified' => true]);
});
test('từ chối mã đã quá 5 phút', function () {
$this->postJson('/api/otp/send', ['phone' => $this->phone]);
$code = sentOtpCode($this->user);
$this->travel(6)->minutes();
$this->postJson('/api/otp/verify', ['phone' => $this->phone, 'code' => $code])
->assertUnprocessable();
});
test('chặn lần gửi thứ 4 trong một phút', function () {
foreach (range(1, 3) as $i) {
$this->postJson('/api/otp/send', ['phone' => $this->phone])->assertOk();
}
$this->postJson('/api/otp/send', ['phone' => $this->phone])->assertTooManyRequests();
Notification::assertSentTimes(OtpCode::class, 3);
});
$this->travel(6)->minutes()có tác dụng vìphpunit.xmlcủa Laravel đặtCACHE_STORE=array, và array store so TTL với giờ của Carbon. Nếu test dùng Redis thì TTL tính theo giờ thật của Redis, travel không làm mã hết hạn.- Rate limiter cũng nằm trong cache đó, nên test nào cũng bắt đầu với bộ đếm sạch.
- Mã sai được chọn dựa trên mã thật (000000, hoặc 111111 nếu mã thật là 000000) nên không bao giờ trùng.
Test SMS channel với Http::fake
Phần trên không chạm tới SmsChannel. Channel phải test riêng bằng Http::fake() của Laravel, và file test này không được gọi Notification::fake(), nếu không thì channel sẽ chẳng bao giờ chạy.
<?php
// tests/Feature/SmsChannelTest.php
use App\Models\User;
use App\Notifications\OtpCode;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config(['services.sms.url' => 'https://sms.example.test/v1', 'services.sms.key' => 'test-key']);
Http::preventStrayRequests(); // URL nào chưa fake mà bị gọi là test nổ ngay
$this->user = User::factory()->create(['phone' => '+84901234567']);
});
test('gọi gateway đúng URL, header và body', function () {
Http::fake(['sms.example.test/v1/messages' => Http::response(['id' => 'msg_1'], 201)]);
$this->user->notify(new OtpCode('123456'));
Http::assertSent(fn (Request $r) => $r->method() === 'POST'
&& $r->url() === 'https://sms.example.test/v1/messages'
&& $r->hasHeader('Authorization', 'Bearer test-key')
&& $r['to'] === '+84901234567'
&& str_contains($r['text'], '123456'));
Http::assertSentCount(1);
});
test('ném RequestException khi gateway trả 500', function () {
Http::fake(['sms.example.test/*' => Http::response(['error' => 'down'], 500)]);
expect(fn () => $this->user->notify(new OtpCode('123456')))
->toThrow(RequestException::class);
});
test('ném ConnectionException khi không kết nối được gateway', function () {
Http::fake(['sms.example.test/*' => Http::failedConnection()]);
expect(fn () => $this->user->notify(new OtpCode('123456')))
->toThrow(ConnectionException::class);
});
Lỗi từ gateway nổi lên tới controller thành 500. Bắt lỗi trong controller hay đưa notification vào queue là chuyện thiết kế, còn test chỉ cần chứng minh lỗi không bị nuốt im lặng.
Smoke test với số thật qua API thuê số
Tầng hai cần một số điện thoại thật nhận được SMS từ staging. Ví dụ trong bài dùng HeroSMS: thuê một số (gọi là activation) cho một dịch vụ ở một quốc gia, poll mã qua REST, xong thì đóng hoặc hủy. Nếu dùng nhà cung cấp khác có REST API tương tự, bạn chỉ cần viết lại HeroSmsClient. Endpoint, mã lỗi và webhook có đủ trong tài liệu API của HeroSMS. Những điểm ảnh hưởng trực tiếp tới code:
- Mọi request đều gửi kèm header
Authorization: ApiKey <token>. Lỗi trả về JSON cótitlevàdetails; vài lỗi có thêminfo, ví dụEARLY_CANCEL_DENIEDcóinfo.minActivationTime. POST /activationsnhậnservice,country(số nguyên),amount: 1,verificationType: 'sms'vàmaxPrice(tùy chọn). Phần tửdata[0]trong phản hồi cóid,phone(số nguyên, không có+),countryPhoneCodevàexpiredAt. Một activation sống khoảng 20 phút, thời điểm hết hạn chính xác nằm ởexpiredAt.GET /activations/{activationId}/otp/lasttrảdata.smsCode(mã) vàdata.smsText(toàn văn tin nhắn), cả hai đều có thể null. Tài liệu không mô tả phản hồi lúc SMS chưa về, nên mình coi 400, 404 hoặcdatarỗng là "chưa có", trừ khititlelàACTIVATION_NOT_ACTIVE,CANCELED,FINISHEDhoặcREFUNDED. Vì vậy truyền sai id thì 404 trông giống hệt timeout.POST /activations/{activationId}/finishđóng activation, không hoàn tiền.DELETE /activations/{activationId}hủy và hoàn tiền. Lệnh hủy có thể bị từ chối vớiOTP_RECEIVED(SMS đã về, phải finish) hoặcEARLY_CANCEL_DENIED(trong ví dụ của tài liệu là khoảng 2 phút đầu).- Mã dịch vụ và ID quốc gia lấy từ endpoint getServicesList và getCountries (cần key). Gọi
GET /activations/offers/smskhông kèm filter cũng ra: mã dịch vụ là key củadata, ID quốc gia là key bên trong từng dịch vụ.
Client viết bằng Laravel HTTP client và đặt trong tests/Support, vì chỉ có test dùng tới:
<?php
// tests/Support/HeroSmsClient.php
namespace Tests\Support;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;
use RuntimeException;
use Throwable;
class HeroSmsClient
{
private const CLOSED = ['ACTIVATION_NOT_ACTIVE', 'CANCELED', 'FINISHED', 'REFUNDED'];
private array $boughtAt = [];
public function __construct(#[\SensitiveParameter] private readonly string $apiKey) {}
private function http(bool $retry = true): PendingRequest
{
$request = Http::baseUrl('https://hero-sms.com/api/v1')
->withToken($this->apiKey, 'ApiKey') // Authorization: ApiKey <key>
->acceptJson()
->timeout(15);
// Chỉ thử lại khi mất kết nối hoặc 5xx; throw: false để 4xx trả về Response
return $retry
? $request->retry(3, 1000, fn (?Throwable $e) => $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError()), throw: false)
: $request;
}
public function buy(string $service, int $country, ?float $maxPrice = null): array
{
// Không retry: request mua bị timeout vẫn có thể đã tạo activation
$a = $this->http(retry: false)->post('/activations', array_filter([
'service' => $service,
'country' => $country,
'amount' => 1,
'verificationType' => 'sms',
'maxPrice' => $maxPrice,
], fn ($v) => $v !== null))->throw()->json('data.0');
if (! $a) {
throw new RuntimeException('POST /activations không trả activation nào');
}
$this->boughtAt[(string) $a['id']] = now();
return ['id' => $a['id'], 'phone' => '+'.$a['phone'], 'expiredAt' => $a['expiredAt'] ?? null];
}
public function waitForCode(int|string $id, int $timeoutSeconds = 120): string
{
$deadline = now()->addSeconds($timeoutSeconds);
$delay = 2000;
while (now()->lessThan($deadline)) {
try {
$response = $this->http()->get("/activations/{$id}/otp/last");
} catch (ConnectionException) {
$response = null; // vẫn mất kết nối sau 3 lần thử: coi như chưa có, poll tiếp
}
$sms = $response?->successful() ? (array) $response->json('data') : [];
$code = $sms['smsCode'] ?? null;
if (! $code && preg_match('/\b\d{4,8}\b/', (string) ($sms['smsText'] ?? ''), $m)) {
$code = $m[0]; // smsCode rỗng: lấy số 4-8 chữ số đầu tiên trong tin
}
if ($code) {
return (string) $code;
}
if (! empty($sms['smsText'])) {
throw new RuntimeException('SMS đã về nhưng không có mã 4-8 chữ số, kiểm tra template');
}
if ($response && ! $response->successful() && ! $response->tooManyRequests()) {
$title = $response->json('title');
if (! in_array($response->status(), [400, 404], true) || in_array($title, self::CLOSED, true)) {
throw new RuntimeException("Dừng poll: HTTP {$response->status()} {$title}");
}
}
$wait = $response?->tooManyRequests() ? ((int) $response->header('Retry-After') ?: 5) * 1000 : $delay;
// ceil: mỗi vòng tiến ít nhất 1 ms, kể cả khi đồng hồ bị fake
$left = (int) ceil(now()->diffInMilliseconds($deadline));
Sleep::for(min($wait, $left))->milliseconds();
$delay = min($delay * 2, 10000);
}
throw new RuntimeException("Không có SMS cho activation {$id} sau {$timeoutSeconds} giây");
}
// Không bao giờ ném lỗi: chạy trong finally, lỗi dọn dẹp không được che lỗi thật của test
public function release(int|string $id, bool $gotSms): void
{
try {
if ($gotSms) {
$this->finish($id);
return;
}
$response = $this->http()->delete("/activations/{$id}"); // hủy, hoàn tiền
if ($response->json('title') === 'EARLY_CANCEL_DENIED') {
$min = (int) ($response->json('info.minActivationTime') ?: 120);
$readyAt = ($this->boughtAt[(string) $id] ?? now())->copy()->addSeconds($min + 5);
Sleep::for(max(0, (int) now()->diffInMilliseconds($readyAt)))->milliseconds();
$response = $this->http()->delete("/activations/{$id}"); // thử lại đúng một lần
}
if ($response->json('title') === 'OTP_RECEIVED') {
$this->finish($id); // SMS về muộn: đóng thay vì hủy
return;
}
$response->throw();
} catch (Throwable $e) {
fwrite(STDERR, "[herosms] release {$id}: {$e->getMessage()}\n");
}
}
private function finish(int|string $id): void
{
// Không gửi body; đóng activation, không hoàn tiền
$this->http()->send('POST', "/activations/{$id}/finish")->throw();
}
}
Vài chỗ đáng nói:
withToken($key, 'ApiKey'): tham số thứ hai là loại token, Laravel ghép thànhtrim($type.' '.$token), ra đúng headerAuthorization: ApiKey <key>.retry(..., throw: false): nếu thiếuthrow: falsemà số lần thử lớn hơn 1, Laravel sẽ némRequestExceptioncho mọi phản hồi 4xx/5xx, kể cả khi closurewhentrả false. Khi đó 404 lúc "chưa có SMS" làm vỡ vòng poll. Closure nhận?Throwablevì có trường hợp Laravel truyền vàonull(phản hồi 3xx không được follow).ConnectionExceptionvẫn bị ném sau lần thử cuối. Vòng poll bắt nó và coi như "chưa có", để vài phút mạng chập chờn không đốt mất một số đã trả tiền. Nếu sau 3 lần thử vẫn là 5xx thì vòng poll dừng.- Vòng poll dùng
Sleepthay chosleep()để test fake được, và có deadline riêng nằm gọn trong 20 phút của activation. Gặp 429 thì đọcRetry-After; tài liệu chỉ ghi header này cho endpoint offers nên có fallback 5 giây. #[\SensitiveParameter]giấu key khỏi stack trace.
Test vòng poll với Http::sequence và Sleep::fake (không tốn tiền)
Sleep::fake(syncWithCarbon: true) bỏ qua thời gian chờ nhưng vẫn đẩy đồng hồ Carbon lên đúng bằng khoảng "đã ngủ". Nhờ vậy deadline vẫn được kiểm tra mà test chạy xong tức thì. Retry của HTTP client cũng sleep qua Sleep, nên cũng không tốn thời gian thật.
<?php
// tests/Feature/HeroSmsClientTest.php (cần app Laravel cho facade nên đặt trong Feature)
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;
use Tests\Support\HeroSmsClient;
beforeEach(function () {
Sleep::fake(syncWithCarbon: true);
Http::preventStrayRequests();
});
test('poll qua 404, 429, data rỗng rồi trả mã', function () {
Http::fake([
'hero-sms.com/api/v1/activations/42/otp/last' => Http::sequence()
->pushStatus(404)
->push('', 429, ['Retry-After' => '3'])
->push(['data' => ['smsCode' => null, 'smsText' => null]])
->push(['data' => ['smsCode' => '482913', 'smsText' => 'Ma xac thuc cua ban la 482913']]),
]);
expect((new HeroSmsClient('test-key'))->waitForCode(42, timeoutSeconds: 60))->toBe('482913');
Http::assertSentCount(4);
Http::assertSent(fn (Request $r) => $r->hasHeader('Authorization', 'ApiKey test-key'));
Sleep::assertSleptTimes(3);
});
test('báo lỗi khi hết deadline mà chưa có SMS', function () {
Http::fake(['hero-sms.com/*' => Http::response([], 404)]);
expect(fn () => (new HeroSmsClient('test-key'))->waitForCode(42, timeoutSeconds: 30))
->toThrow(RuntimeException::class, 'Không có SMS');
});
test('chuyển sang finish khi hủy bị từ chối vì OTP_RECEIVED', function () {
// Một closure fake thay cho nhiều pattern chồng nhau.
// Status 400 là giả định: tài liệu không ghi status, code chỉ đọc title
Http::fake(fn (Request $r) => match ($r->method()) {
'DELETE' => Http::response(['title' => 'OTP_RECEIVED'], 400),
'POST' => Http::response(['data' => []]),
});
(new HeroSmsClient('test-key'))->release(42, gotSms: false);
Http::assertSent(fn (Request $r) => $r->method() === 'POST'
&& $r->url() === 'https://hero-sms.com/api/v1/activations/42/finish');
});
Ba test này không tốn đồng nào và chạy ở mọi PR.
Bẫy khi dùng Http::fake sequence: pattern URL chồng nhau
Test release() ở trên dùng một closure thay cho nhiều pattern là có lý do. Thử viết bằng pattern xem:
// SAI: URL .../activations/42/otp/last khớp cả hai pattern
Http::fake([
'hero-sms.com/api/v1/activations/*' => Http::sequence()->push(['data' => []]),
'hero-sms.com/api/v1/activations/*/otp/last' => Http::sequence()
->pushStatus(404)
->push(['data' => ['smsCode' => '482913', 'smsText' => null]]),
]);
Laravel ngầm thêm * vào đầu pattern, mà * khớp được cả dấu /, nên activations/* cũng khớp activations/42/otp/last. Pattern đăng ký trước sẽ thắng, nên request poll nhận phản hồi từ sequence đầu tiên. Tệ hơn nữa, mọi stub khớp URL đều được gọi ở mỗi request, nên sequence thứ hai vẫn bị rút mất một phần tử dù phản hồi của nó không được dùng. Sequence hết phần tử mà không có whenEmpty() thì ném exception, và test đỏ ở một chỗ chẳng liên quan. Cách sửa: tách pattern để chúng không chồng nhau, hoặc gom về một closure duy nhất rẽ nhánh theo method và path, như test release() ở trên.
Smoke test bằng Pest: gọi staging với số thật
<?php
// tests/Feature/Smoke/OtpRealSmsTest.php
use Illuminate\Support\Facades\Http;
use Tests\Support\HeroSmsClient;
test('staging gửi OTP SMS thật và chấp nhận đúng mã', function () {
$country = filter_var(env('OTP_COUNTRY'), FILTER_VALIDATE_INT);
$maxPrice = env('HEROSMS_MAX_PRICE');
if (blank(env('OTP_SERVICE')) || $country === false || blank(env('STAGING_URL'))
|| (filled($maxPrice) && ! is_numeric($maxPrice))) {
throw new RuntimeException('Cần OTP_SERVICE, OTP_COUNTRY (số nguyên), STAGING_URL; HEROSMS_MAX_PRICE nếu có phải là số');
}
$staging = Http::baseUrl(env('STAGING_URL'))->acceptJson()->timeout(20);
$sms = new HeroSmsClient(env('HEROSMS_API_KEY'));
$number = $sms->buy(env('OTP_SERVICE'), $country, filled($maxPrice) ? (float) $maxPrice : null);
$gotSms = false;
try {
$staging->post('/api/otp/send', ['phone' => $number['phone']])->throw();
$code = $sms->waitForCode($number['id'], timeoutSeconds: 120);
$gotSms = true;
$wrong = $code === '000000' ? '111111' : '000000';
$rejected = $staging->post('/api/otp/verify', ['phone' => $number['phone'], 'code' => $wrong]);
expect($rejected->status())->toBe(422);
$accepted = $staging->post('/api/otp/verify', ['phone' => $number['phone'], 'code' => $code]);
expect($accepted->status())->toBe(200)
->and($accepted->json('verified'))->toBeTrue();
} finally {
$sms->release($number['id'], $gotSms); // có SMS thì finish, không thì hủy để hoàn tiền
}
})->group('real-sms')->skip(fn () => blank(env('HEROSMS_API_KEY')), 'Chưa có HEROSMS_API_KEY');
- Kiểm tra biến môi trường trước khi mua. Nếu không kiểm tra,
OTP_COUNTRYrỗng bị ép kiểu thành 0, hayHEROSMS_MAX_PRICEgõ nhầm thành0,5bị ép thành 0.0, mà request mua vẫn đi. ->group('real-sms')để job cho PR loại test này ra, còn->skip()là lớp bảo vệ thứ hai cho máy dev không có key.- Nếu viết test dạng class với PHPUnit thì gắn attribute
#[Group('real-sms')](namespacePHPUnit\Framework\Attributes\Group) lên class; lệnh--groupvà--exclude-groupgiữ nguyên. - Smoke test chỉ có ý nghĩa khi staging gửi SMS qua gateway thật, với template giống production.
Chạy test Laravel trên GitHub Actions: PR và smoke test theo lịch
Job cho PR loại group real-sms ra. php artisan test chuyển mọi tham số sang Pest hoặc PHPUnit:
# .github/workflows/tests.yml
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, pdo_sqlite
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env && php artisan key:generate
- run: php artisan test --exclude-group=real-sms
Job chạy theo lịch (mỗi sáng ngày làm việc) chỉ chạy group real-sms:
# .github/workflows/otp-smoke.yml
name: otp-smoke
on:
schedule:
- cron: '17 7 * * 1-5' # 7h17 giờ Việt Nam, thứ 2 đến thứ 6
timezone: 'Asia/Ho_Chi_Minh'
workflow_dispatch:
concurrency:
group: otp-smoke
cancel-in-progress: false # run bị hủy giữa chừng thì finally không kịp chạy
jobs:
real-sms:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, pdo_sqlite
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env && php artisan key:generate
- name: Smoke test OTP với số thật
env:
HEROSMS_API_KEY: ${{ secrets.HEROSMS_API_KEY }}
OTP_SERVICE: ${{ vars.OTP_SERVICE }}
OTP_COUNTRY: ${{ vars.OTP_COUNTRY }}
HEROSMS_MAX_PRICE: ${{ vars.HEROSMS_MAX_PRICE }}
STAGING_URL: ${{ vars.STAGING_URL }}
run: |
test -n "$HEROSMS_API_KEY" || { echo "::error::Thiếu secret HEROSMS_API_KEY"; exit 1; }
php artisan test --group=real-sms --log-junit real-sms-junit.xml
- uses: actions/upload-artifact@v7
if: always()
with:
name: real-sms-junit
path: real-sms-junit.xml
- Key đi qua
env:của step chứ không nằm trên command line, đúng khuyến nghị về secrets của GitHub. Biến môi trường thật được ưu tiên hơn giá trị trong.env, nên file copy từ.env.examplekhông đè lên chúng. - Dòng
test -nlàm job đỏ khi secret bị xóa hoặc đổi tên. Thiếu dòng này,->skip()sẽ bỏ qua test và job theo lịch cứ xanh mãi mà không ai hay. - Không khai báo
timezonethì cron tính theo UTC.Asia/Ho_Chi_Minhkhông có giờ mùa hè, nên 7h17 là 7h17 quanh năm. Mình cho chạy buổi sáng để team thấy kết quả ngay đầu ngày làm việc. Phút lẻ là cố ý chọn, vì lúc tải cao, nhất là đầu mỗi giờ, run theo lịch có thể bị trễ. - Workflow này không có trigger
pull_request, nên không PR nào, kể cả PR từ fork, tiêu được số dư. - Runner của GitHub phải gọi được staging qua internet. Nếu staging nằm trong mạng nội bộ thì dùng self-hosted runner.
Chi phí và dùng đúng mục đích
Mỗi lần chạy thuê đúng một số. Giá tùy theo dịch vụ và quốc gia; HEROSMS_MAX_PRICE đặt mức trần để lỡ giá có tăng thì chi phí cũng không âm thầm đội lên. Lịch thứ 2 đến thứ 6 tốn khoảng 22 số mỗi tháng, cộng thêm các lần chạy tay. Không có SMS thì release() hủy activation và tiền về lại số dư. Nếu lệnh hủy bị từ chối vì lý do khác, release() chỉ ghi log và số vẫn mở tới khi hết hạn. SMS đã về thì vẫn phải trả tiền, kể cả khi app từ chối mã. Nếu runner chết hẳn, finally không chạy: số chưa dùng vẫn được hoàn, và GET /activations liệt kê các activation còn mở. HeroSMS có số ở hơn 180 quốc gia, nên thường bạn chạy được smoke test bằng số của chính thị trường mà app sắp mở. Trước khi đặt OTP_COUNTRY, kiểm tra xem ID nước đó có nằm trong GET /activations/offers/sms, dưới mã dịch vụ của bạn, hay không.
Chỉ test app của bạn hoặc app bạn được phép test. Dùng số thuê để lách giới hạn đăng ký hay xác minh của dịch vụ khác thường vi phạm điều khoản của họ. Số thuê chỉ thuộc về bạn trong thời gian activation, nên đừng gắn nó với tài khoản cần dùng lâu dài. User test sinh ra trên staging mỗi ngày cũng nên được dọn định kỳ.
Lỗi thường gặp và cách xử lý
Http::fake không có tác dụng (laravel http fake not working)
Request vẫn đi ra ngoài, hoặc Http::assertSent không thấy gì. Có ba nguyên nhân hay gặp. Một: code không gọi qua facade Http mà qua một SDK có Guzzle client riêng, trong khi Http::fake chỉ chặn Laravel HTTP client. Hai: pattern không khớp. Laravel chỉ ngầm thêm * ở đầu pattern chứ không thêm ở cuối, nên sms.example.test/v1 không khớp https://sms.example.test/v1/messages; hãy viết sms.example.test/*. Ba: theo docs, URL nào chưa được fake thì vẫn được gửi thật. Http::preventStrayRequests() biến những request đó thành exception; nếu bật nó cho mọi test trong tests/Pest.php, nhớ gọi Http::allowStrayRequests() trong smoke test.
RequestException ngay lần poll đầu khi API trả 404
Bạn dùng retry(3, ...) mà thiếu throw: false, xem lại phần client.
Test SmsChannel không thấy request nào
Test đó đang chạy dưới Notification::fake(), có thể do một beforeEach dùng chung. Tách test channel ra file riêng.
Smoke test không nhận được mã
Kiểm tra theo thứ tự: staging có gửi qua gateway thật không; app có nhận số dạng +<mã quốc gia><số> không (nếu form tách riêng mã quốc gia thì cắt countryPhoneCode khỏi phone); OTP_SERVICE có khớp với dịch vụ đang gửi SMS không, vì activation chỉ dành cho một dịch vụ. Nếu app chặn vài đầu số thì thêm phoneException vào body request mua để loại chúng từ đầu. POST /activations/{id}/replace đổi sang số khác miễn phí; nếu dùng nó thì nhớ release activation mới mà nó trả về.
Lỗi 429 Too Many Requests
Nếu 429 đến từ HeroSMS: vòng poll đã đọc Retry-After, và concurrency đảm bảo hai run không poll cùng lúc. Nếu đến từ chính app: throttle:otp chỉ cho 3 lần gửi mỗi phút với mỗi số, nên smoke test mà tự retry bước gửi thì phải tính tới giới hạn này.
Activation đã đóng hoặc hết hạn
Vòng poll dừng ngay khi title là ACTIVATION_NOT_ACTIVE, CANCELED, FINISHED hoặc REFUNDED. Deadline 2 phút nằm gọn trong khoảng 20 phút của activation; nếu muốn poll lâu hơn thì so với expiredAt.
Lịch GitHub Actions không chạy
Lịch chỉ chạy từ default branch, nên file workflow phải được merge vào đó. Repo public không có hoạt động nào trong 60 ngày thì schedule bị tắt. Bấm "Run workflow" (workflow_dispatch) để thử ngay mà không cần chờ cron.
FAQ
Nên dùng Pest hay PHPUnit?
Team đang dùng cái nào thì dùng cái đó. Pest 5 cần PHP 8.4, còn bản pest-plugin-laravel 5 hiện tại (5.0.1) cần Laravel 13.23 trở lên, nên dự án Laravel 12 cứ dùng Pest 4. Các assert của fake (Notification::assertSentTo, Http::assertSent, Sleep::assertSleptTimes) là của Laravel nên dùng được với cả Pest lẫn PHPUnit; chỉ phần expect() là cú pháp riêng của Pest.
Notification::fake() có gửi SMS không?
Không. Không channel nào chạy, nên cũng không có request HTTP nào tới gateway.
Test OTP hết hạn thế nào?
Gửi mã, gọi $this->travel(6)->minutes(), rồi verify phải trả 422. Cache store dùng trong test phải tính TTL theo Carbon, như store array mặc định.
Có cần số thật ở mỗi PR không?
Không. Mock bắt lỗi logic nhanh và miễn phí. Số thật chỉ trả lời câu hỏi "SMS có thực sự tới không", nên chạy hằng ngày theo lịch và trước release là đủ.
Http::fake sequence dùng thế nào?
Mỗi request khớp pattern sẽ lấy một phản hồi theo thứ tự; hết phần tử mà không có whenEmpty thì ném exception. Ví dụ: Http::sequence()->push([...])->pushStatus(404)->whenEmpty(Http::response()). Http::fakeSequence('sms.example.test/*') là cách viết tắt khi chỉ có một pattern. Nhớ tránh các pattern chồng nhau như trong phần bẫy ở trên.
Dùng webhook thay polling được không?
Được, API có webhook sms-incoming đẩy SMS tới URL của bạn. Nhưng runner cần một URL public để nhận, nên với smoke test PHP thì poll đơn giản hơn. Cách dựng webhook mình đã viết trong bài Playwright nhận OTP qua webhook.
Kết luận
Fake ở mỗi PR trả lời câu hỏi "code có đúng không", còn số thật theo lịch trả lời câu hỏi "SMS có tới tay user không". Cần cả hai. Checklist trước khi merge:
- Feature test với
Notification::fake: đúng user, mã sai trả 422, mã hết hạn bị từ chối, lần gửi thứ 4 trả 429. SmsChannelđược test riêng vớiHttp::fakevàpreventStrayRequests, có case 500 và mất kết nối.- Không có cờ
SKIP_OTPnào trong backend. - Client gọi API số thật:
retry(..., throw: false), không retry request mua, poll có deadline, dùngSleepvà chịu được mạng chập chờn. finallyluôn finish hoặc hủy activation, lỗi dọn dẹp chỉ ghi log.- PR chạy
--exclude-group=real-sms; job theo lịch chạy--group=real-sms, key nằm trong secrets và có bước kiểm tra,HEROSMS_MAX_PRICEnằm trong vars.
All rights reserved