« back published by @mmartin_joo on December 25, 2021

Test-Driven API with Laravel and Pest (part 2)

The is the second part of the Test-driven API with Laravel and Pest series. You can read the first part here. In the first one we’ve created the categories API, now we continue with the products API.

In this post, we’re gonna build a simple CRUD API using Laravel and Pest which is an amazing test framework. The goal of this post is to show you how to practice test-driven development or TDD.

In this application, we have products, prices, and categories. A category has many products, a product belongs to one category, and a product has many prices. A price has a from and to date attribute, for example, “Product A” has a price of $19 from 01/01/2022 to 01/31/2022 but has a different price from 01/02/2022 and so on.

In this tutorial, I’m gonna write API tests, because I believe that’s the most effective and most maintainable way to practice TDD.

You can find the whole repo on Github.

Upsert a product

By upsert I mean creating and updating. Products are a little bit more complicated than categories were, so first let’s take a look at the request:

public function rules()
{
   return [
       'id' => 'nullable|sometimes|exists:products,id',
       'name' => ['required','string', Rule::unique('products', 'name')->ignore($this->product?->id)],
       'description' => 'nullable|sometimes|string|min:10',
       'prices' => 'required|array',
       'prices.*.fromDate' => 'required|date',
       'prices.*.toDate' => 'required|date',
       'prices.*.price' => 'required|numeric',
       'categoryId' => 'required|exists:categories,id',
   ];
}

We have some basic attributes and prices which is an array. Each price has a from_date, a to_date, and a price. We’ll save them into the product_prices table that has a product_id foreign key. As we saw in part 1, we use the ignore method to avoid incorrect validation errors when updating a product. Now, let’s write some tests:

it('should create a product', function () {
   $category = Category::factory()->create();

   $product = postJson(route('products.store'), [
       'categoryId' => $category->id,
       'name' => 'Microservices with Laravel',
       'description' => 'The ultimate guide to build microservices with Laravel',
       'prices' => [
           ['fromDate' => now()->subDays(2), 'toDate' => now()->addMonth(), 'price' => 15],
           ['fromDate' => '2020-11-01', 'toDate' => '2020-11-30', 'price' => 12],
           ['fromDate' => '2020-10-01', 'toDate' => '2020-10-31', 'price' => 9],
       ],
   ])->assertStatus(Response::HTTP_CREATED)->json('data');

   expect($product)
       ->currentPrice->toBe(15)
       ->name->toBe('Microservices with Laravel')
       ->description->toBe('The ultimate guide to build microservices with Laravel')
       ->category->toBeArray()
       ->prices->toHaveCount(3);
});

First, we create the category then send the POST request with some data. We expect the status code to be 201. After that, we simply check the product’s properties. We have a toBeCount() expectation that simply checks that the given array or collection has the correct length. Later we will see other array-related expectations.

Creating and updating products is a lot more complicated than creating or updating categories. So in this case we want to generalize the two methods. Let’s see the Controller:

public function store(StoreProductRequest $request)
{
   return response([
       'data' => new ProductResource($this->upsert($request, new Product()))
   ], Response::HTTP_CREATED);
}

public function update(StoreProductRequest $request, Product $product)
{
   return response([
       'data' => new ProductResource($this->upsert($request, $product))
   ], Response::HTTP_OK);
}

private function upsert(StoreProductRequest $request, Product $product): Product
{
   $product = $this->createProduct->handle(
       Category::find($request->getCategoryId()),
       $request->getName(),
       $request->getPrices(),
       $request->getDescription(),
       $product->id,
   );

   $product->load('category');
   return $product;
}

We use the same upsert method in both cases. When we update a product we of course have a product instance, but when we create a new one we don’t have a product. This is why we pass a new product instance to the upsert in the store method.

I like to write getters on the Request object. In this way, it’s much cleaner for me.

Now, what the hell is $this->upsertProduct? It’s a service class used specifically to create new products. You can also call it an Action class if you wish. In my opinion, it’s a best practice to create use case specific services instead of some general ProductService that has a create method. Here’s how it looks like:

return DB::transaction(function () use ($category, $name, $description, $prices, $id) {
   $product = Product::updateOrCreate(
       [
           'id' => $id,
       ],
       [
           'category_id' => $category->id,
           'description' => $description,
           'name' => $name,
       ]
   );

   $productPrices = collect($prices)
       ->map(fn (array $priceData) => new ProductPrice([
           'from_date' => $priceData['fromDate'],
           'to_date' => $priceData['toDate'],
           'price' => $priceData['price'],
       ]));

   $product->prices()->delete();
   $product->prices()->saveMany($productPrices);
   return $product;
});

We need to save the product and also the prices which means multiple database inserts (or updates), so we wrap everything in a database transaction. In this method, we use the createOrUpdate Eloquent method. The first array is like a where clause. If it finds a product with the given ID then it’s gonna be an update, if it’s not found then it’s an insert. The second array is the data that we want to insert or update.

After the createOrUpdate magic, we map every item in the $prices array to a new ProductPrice instance. No database query happening here, we just create some model objects.

Now we need to sync the product’s prices with the new ones. That means deleting everything from the database and inserting the new ones from the request. Unfortunately, we don’t have a sync() method for one-to-many relations so we do it manually. The saveMany is a ver handy method, it can save many relations at once. Laravel also has a createMany. It works with arrays instead of models. And finally, we return the brand new product.

Now let’s see some test for update:

it('should update prices', function () {
   $category = Category::factory()->create();

   $product = Product::factory([
       'name' => 'First Product',
       'description' => 'First description',
       'category_id' => $category->id,
   ])
       ->has(ProductPrice::factory()->count(3), 'prices')
       ->create();

   $data = [
       'name' => 'First product',
       'description' => 'First description',
       'categoryId' => $category->id,
       'prices' => [
           [
               'fromDate' => '2021-10-01',
               'toDate' => '2021-10-30',
               'price' => 10,
           ]
       ]
   ];

   $updatedProduct = putJson(
       route('products.update', compact('product')),
       $data,
   )
   ->assertStatus(Response::HTTP_OK)
   ->json('data');

   expect($updatedProduct)
       ->prices->toMatchArray([
           [
               'fromDate' => Carbon::parse('2021-10-01')->toISOString(),
               'toDate' => Carbon::parse('2021-10-30')->toISOString(),
               'price' => 10,
           ]
       ]);
});

At this point (with part 1) is kind of similar:

  • We create a category and a product with 3 prices
  • We set up the post data
  • Send the POST request and assert the status code
  • Finally, we assert that we get back only one price from the API

So before the update, the product had 3 prices, but we overrode it with only one price. In this test, we use the toMatchArray expectation. It simply compares two arrays.

Get products

After upserting let’s see something simple like listing the products. The only tricky part is that we need to display the current price for every product. It’s calculated by the current date of course. We can use an attribute accessor for that purpose:

protected $appends = ['current_price'];

public function getCurrentPriceAttribute(): ?float
{
   return $this->prices
       ->where('from_date', '<=', now())
       ->where('to_date', '>=', now())
       ->first()?->price;
}

Attribute accessor is a Laravel concept. We can define getColumnNameAttribute methods in the model and we can it like $model->column_name as if it was a database column. You can also put it in the $appends array and the current_price will be appended to the model when Laravel converts it to JSON. So every time you return it from a Controller. But in this project, we use Resources so we don’t need the $appends. We can simply reference the current_price:

class ProductResource extends JsonResource
{
   public function toArray($request)
   {
       return [
           'id' => $this->id,
           'name' => $this->name,
           'description' => $this->description,
           'category' => new CategoryResource($this->whenLoaded('category')),
           'currentPrice' => $this->current_price,
           'prices' => ProductPriceResource::collection($this->prices),
       ];
   }
}

Now we can write some tests:

it('should return every products', function () {
   Product::factory()->count(3)->create();

   $products = getJson(route('products.index'))
       ->assertStatus(Response::HTTP_OK)
       ->json('data');

   expect($products)->toHaveCount(3);
});

At this point, you don’t need an explanation for that one.

it('should return a product with the current price', function () {
   $product = Product::factory()
       ->has(ProductPrice::factory([
           'from_date' => now()->subDay(),
           'to_date' => now()->addDay(),
           'price' => 10,
       ]), 'prices')
       ->create();

   $productResponse = getJson(route('products.show', compact('product')))
       ->assertStatus(Response::HTTP_OK)
       ->json('data');

   expect($productResponse)->currentPrice->toBe(10);
});

In this one, we check the current_price attribute. The magic happens in the factory, where we specify the from and to date relative to now so it will work any time.

What happens if the product has many prices? We can test it by traveling time!

it('should return the right current price at any given time', function () {
   $product = Product::factory()
       ->has(ProductPrice::factory([
           'from_date' => now()->subDay(),
           'to_date' => now()->addDay(),
           'price' => 10,
       ]), 'prices')
       ->has(ProductPrice::factory([
           'from_date' => now()->addMonth(),
           'to_date' => now()->addMonths(2),
           'price' => 15,
       ]), 'prices')
       ->create();

   $this->travelTo(now()->addMonth(), function () use ($product) {
       $productResponse = getJson(route('products.show', compact('product')))
           ->assertStatus(Response::HTTP_OK)
           ->json('data');

       expect($productResponse)->currentPrice->toBe(15);
   });
});

We create a product with two prices. One is in the future, and one is in our present days. But let’s say we want to test what happens when we check the product a month later. We can test it with Laravel’s travelTo helper. It just calls the Carbon::setTestNow() function which sets the current time in Carbon to a specific date. So it’s similar when you set the time on your computer. Suddenly ‘now’ means something else. Please note that this will only work with Carbon. So in the attribute accessor, we need to use Carbon or some Laravel helper function like now(). After the time travel, we simply check that the currentPrice is the future one.

Delete a product

Our last feature is the destruction of a product. It’s gonna be simple:

it('should delete a product with prices', function () {
   $product = Product::factory()
       ->has(ProductPrice::factory()->count(2), 'prices')
       ->create();

   deleteJson(route('products.destroy', compact('product')))
       ->assertStatus(Response::HTTP_NO_CONTENT);

   $this->assertDatabaseMissing('product_prices', [
       'product_id' => $product->id,
   ]);
});

We need to make sure that prices are also deleted. It makes no sense to have a bunch of prices without a product. Once again we ensure this in the migration:

$table->foreignId('product_id')
   ->index()
   ->references('id')
   ->on('products')
   ->onDelete('cascade');

The destroy function on the controller is a one-liner:

public function destroy(Product $product)
{
   $product->delete();
   return response([], Response::HTTP_NO_CONTENT);
}

Today we used red-green-refactor which you can apply to simple things like this API. And it means:

  • First, you write a test that describes your use case. At this stage, you can really understand the specs, you can design and think about your API, or how you want to name your classes, functions, and so on. This is the red stage because your tests fail. There is no real code yet.
  • After that, you can write the minimal implementation code that makes your tests pass. Nothing fancy, just the bare minimum. Now you can think about some edge cases, more tests. This is the green stage because your tests pass.
  • After you have the minimal implementation and you have tests, finally you can refactor and make your code better.

If you want to learn more about Test-Driven Development and APIs, check out my eBook Test-Driven APIs with Laravel and Pest.