visit
We have devices in the wild (Device model
) reporting temperatures (Sample model
). Every sample is stored in a database.
Our task: create an API to return JSON
with the last 100 samples across the whole platform and provide the device’s hardware ID and location for every sample.
{
"data": [
{
"temp": 18,
"hardware_id": "8381fa1a-d2b3-3c67-815a-6884b80099d4",
"location": "Rauport",
"datetime": "2022-07-26 16:03:32"
},
{
"temp": -8,
"hardware_id": "e934c789-2326-37ab-82cc-37aec840fcff",
"location": "Braxtonhaven",
"datetime": "2022-07-26 16:05:02"
},
{
"temp": -5,
"hardware_id": "4694d32b-4ce2-3971-94f7-76664fbf872c",
"location": "Thaliaberg",
"datetime": "2022-07-26 16:09:59"
}
]
}
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Device extends Model
{
use HasFactory;
protected $fillable = [
'hardware_id',
'location',
];
public function samples(): HasMany
{
return $this->hasMany(Sample::class);
}
}
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Sample extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'device_id',
'temp',
'created_at',
];
public function device(): BelongsTo
{
return $this->belongsTo(Device::class);
}
}
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Device;
use App\Models\Sample;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$devices = Device::factory()->count(20)->create();
$ids = $devices->pluck('id');
for ($i = 0; $i < 100; $i++) {
Sample::factory()->create([
'device_id' => $ids->random(1)->first(),
]);
}
}
}
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Sample;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property-read Sample $resource
*/
class SampleResource extends JsonResource
{
public function toArray($request): array|\JsonSerializable|Arrayable
{
return [
'temp' => $this->resource->temp,
'hardware_id' => $this->resource->device?->hardware_id,
'location' => $this->resource->device?->location,
'datetime' => $this->resource->created_at,
];
}
}
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Resources\SampleResource;
use App\Models\Sample;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class SampleController extends Controller
{
public function last100(): AnonymousResourceCollection
{
return SampleResource::collection(
Sample::latest()->limit(100)->get()
);
}
}
You can see here we introduced an N+1 query problem. The fix, in this case, is very easy and will just be adding ->with(['device'])
.
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Resources\SampleResource;
use App\Models\Sample;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class SampleController extends Controller
{
public function last100(): AnonymousResourceCollection
{
return SampleResource::collection(
Sample::latest()->with(['device'])->limit(100)->get()
);
}
}
<?php
/** @test */
public function last100_lazy_load_disabled_when_correct_request_then_has_expected_query_count(): void
{
Model::preventLazyLoading();
$this->seed(DatabaseSeeder::class);
// act
$this->withoutExceptionHandling();
$response = $this->getJson(route('api.last100'));
// assert
$response->assertOk();
}
**Illuminate\Database\LazyLoadingViolationException : Attempted to lazy load [device] on model [App\Models\Sample] but lazy loading is disabled.**
<?php
/** @test */
public function last100_debugbar_when_correct_request_then_has_expected_query_count(): void
{
$this->seed(DatabaseSeeder::class);
$debugbar = new LaravelDebugbar();
$debugbar->boot();
// act
$this->getJson(route('api.last100'));
// assert
$queryCount = count($debugbar->collect()['queries']['statements']);
$this->assertSame(2, $queryCount);
}
We expect our route to only use 2 queries: 1st would be to select 100 samples, second to select all devices with whereIn(…devices_id…)
.
**_Failed asserting that 101 is identical to 2._**
PRO TIP:
Use DEBUGBAR_ENABLED=false
within your phpunit.xml
or .env.testing
so tests don’t have additional debug overhead (when not required).
<?php
/** @test */
public function last100_telescope_when_correct_request_then_has_expected_query_count(): void
{
// phpunit.xml: change => <env name="TELESCOPE_ENABLED" value="true"/>
$this->seed(DatabaseSeeder::class);
/** @var EntriesRepository $storage */
$storage = resolve(EntriesRepository::class);
// act
$this->getJson(route('api.last100'));
// assert
$entries = $storage->get(
EntryType::QUERY,
(new EntryQueryOptions())->limit(100)
);
// finds all queries executed in SampleResource file
$queryCount = $entries->filter(fn($e) => str_contains($e->content['file'], 'SampleResource'))
->count();
$this->assertSame(0, $queryCount);
}
We expected our SampleResource
file to not execute any queries. When running this test with our broken controller we get a fail:
**_Failed asserting that 100 is identical to 0._**
PRO TIP:
Use TELESCOPE_ENABLED=false
within your phpunit.xml
or .env.testing
so tests don’t have additional debug overhead (when not required).
BUT you need it set to true
for my example test case to work.
Let me know if you have a solution to this, I want TELESCOPE_ENABLED=false
but enabled on an ad-hoc basis during the test case.
<?php
/** @test */
public function last100_dbquerylog_when_correct_request_then_has_expected_query_count(): void
{
$this->seed(DatabaseSeeder::class);
// act
DB::enableQueryLog();
$this->getJson(route('api.last100'));
DB::disableQueryLog();
// assert
$queryLog = DB::getQueryLog();
$queryCount = collect($queryLog)->filter(
fn($log) => str_contains($log['query'], 'select * from "devices" where "devices"."id"')
)->count();
// we expected only 1 query for all devices
$this->assertSame(1, $queryCount);
}
**_Failed asserting that 100 is identical to 1._**
The sample repository for this article can be found here.