Laravel — HTTP Testing without Database

Recently, inspired by my coworker, I’ve started to explore on the possibility to perform HTTP testing in Laravel without involving the database layer.

After some research, I’ve found that this is similar to the implementation of end-to-end testing as documented in JavaScript framework NestJS. Basically, this is similar to Feature test in Laravel, except that the data access class is being replaced with a test double.

Unlike unit testing, which focuses on individual modules and classes, end-to-end (e2e) testing covers the interaction of classes and modules at a more aggregate level — closer to the kind of interaction that end-users will have with the production system.

Why would we do this?

Firstly, when involving the database in the test, it’s easy for the test suite to grow bigger and take longer time if we’re not taking good care of it. For example, although Laravel provides different way to optimize, the seeding and migration that is executed might still take up considerable chunk of time.

Secondly, when the application grows, we might want to introduce different data source (for example, the popular Repository pattern). In that case, it makes sense for the test to not having dependency on the database.

Code

Assuming we have a normal REST-based API endpoints on books, the Controller goes like this. Here we’ll inject the Eloquent Model class which provides the data, but this could easily be switched to another Repository or Service class:

use App\Models\Book;class BookController extends Controller
{
public function __construct(Book $book) {
$this->book = $book;
}
public function index() {
return BookResource::collection($this->book->paginate());
}
public function store(StoreBookRequest $request) {
$createdBook = $this->book->create($request->all());
return BookResource::make($createdBook);
}
...

To make the test case, first we’ll have to pass the authentication. Here, we can make use of actingAs method to set the current User, which can be generated from UserFactory, without actually saving it to database.

protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->make();
$this->actingAs($this->user);
}

The test case for listing the books. Here, the Eloquent Model class is mocked with a test double which will return an LengthAwarePaginator object (the return value of paginate()) so that no actual database calls will be executed. The book collection are generated from the factory class but are not persisted. Afterwards, the HTTP assertions can be made as usual.

public function test_can_list_books()
{
$books = Book::factory()
->count(2)
->make()
->sortBy('isbn')
->values();
$this->mock(Book::class, function ($mock) use ($books) {
$paginated = new LengthAwarePaginator(
$books,
$books->count(),
10
);
$mock->shouldReceive('paginate')
->once()
->andReturn($paginated);
});
$this->getJson('/api/books')
->assertSuccessful()
->assertJsonFragment(['data' => $books])
->assertJsonCount($books->count(), 'data');
}

The test case for storing book. Similarly, the actual call to persist the book is mocked.

public function test_can_store_book()
{
$newBook = Book::factory()->make();
$this->mock(Book::class, function ($mock) use ($newBook) {
$mock->shouldReceive('create')
->withArgs([$newBook->toArray()])
->once()
->andReturn($newBook);
});
$this->postJson('/api/books', $newBook->toArray())
->assertSuccessful()
->assertJson(['data' => $newBook->toArray()]);
}

The test case for request validation. We can still perform test on Form Request normally.

public function test_cannot_store_book_without_isbn()
{
$newBook = Book::factory()->make();
$this->postJson('/api/books', $newBook->only('title'))
->assertStatus(422);
}

The full code for the test class. Notice that RefreshDatabase trait is not included, therefore the database won’t be set and reset on each test.

On my local laptop, the test is able to complete within split second:

Thanks for reading through! Feel free to leave any comment or contact me on weihien90@gmail.com

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store