In this lesson, let's practice TDD with Unit tests. We will try to refactor one Apartment model feature and write a Unit test for it, instead of the existing Feature test. I will explain the differences along the way.
The idea came from my YouTube channel where I posted the idea of refactoring this Model function into a Service:
app/Models/Apartment.php:
public function calculatePriceForDates($startDate, $endDate){ // Convert to Carbon if not already if (!$startDate instanceof Carbon) { $startDate = Carbon::parse($startDate)->startOfDay(); } if (!$endDate instanceof Carbon) { $endDate = Carbon::parse($endDate)->endOfDay(); } $cost = 0; while ($startDate->lte($endDate)) { $cost += $this->prices->where(function (ApartmentPrice $price) use ($startDate) { return $price->start_date->lte($startDate) && $price->end_date->gte($startDate); })->value('price'); $startDate->addDay(); } return $cost;}
And I got a few comments: how about making this service testable?
Not only that, maybe we should try TDD?
Fair enough, let's try to do exactly that.
Why UNIT Test Here?
Before getting to the code syntax, let's start with WHY you would do that.
Currently, we have calculations of apartment prices which are not easy to test with automated tests.
Well, in fact, we do have Feature tests for exactly that, remember?
tests/Feature/ApartmentPriceTest.php:
class ApartmentPriceTest extends TestCase{ use RefreshDatabase; private function create_apartment(): Apartment { $owner = User::factory()->create(['role_id' => Role::ROLE_OWNER]); $cityId = City::value('id'); $property = Property::factory()->create([ 'owner_id' => $owner->id, 'city_id' => $cityId, ]); return Apartment::create([ 'name' => 'Apartment', 'property_id' => $property->id, 'capacity_adults' => 3, 'capacity_children' => 2, ]); } public function test_apartment_calculate_price_1_day_correctly() { $apartment = $this->create_apartment(); ApartmentPrice::create([ 'apartment_id' => $apartment->id, 'start_date' => now()->toDateString(), 'end_date' => now()->addDays(10)->toDateString(), 'price' => 100 ]); $totalPrice = $apartment->calculatePriceForDates( now()->toDateString(), now()->toDateString() ); $this->assertEquals(100, $totalPrice); } public function test_apartment_calculate_price_2_days_correctly() { $apartment = $this->create_apartment(); ApartmentPrice::create([ 'apartment_id' => $apartment->id, 'start_date' => now()->toDateString(), 'end_date' => now()->addDays(10)->toDateString(), 'price' => 100 ]); $totalPrice = $apartment->calculatePriceForDates( now()->toDateString(), now()->addDay()->toDateString() ); $this->assertEquals(200, $totalPrice); } public function test_apartment_calculate_price_multiple_ranges_correctly() { $apartment = $this->create_apartment(); ApartmentPrice::create([ 'apartment_id' => $apartment->id, 'start_date' => now()->toDateString(), 'end_date' => now()->addDays(2)->toDateString(), 'price' => 100 ]); ApartmentPrice::create([ 'apartment_id' => $apartment->id, 'start_date' => now()->addDays(3)->toDateString(), 'end_date' => now()->addDays(10)->toDateString(), 'price' => 90 ]); $totalPrice = $apartment->calculatePriceForDates( now()->toDateString(), now()->addDays(4)->toDateString() ); $this->assertEquals(3*100 + 2*90, $totalPrice); }}
But, to launch this test method, we need to prepare things in the database:
- "Core" seeders
- Create a Property and Apartment Eloquent models
- Collection of Prices for that apartment
And the problem is that it's quite "expensive" to launch all the migrations, seeds, and factories with data, just to test if the calculation of the prices is correct.
What if we could separate that calculation of prices into its own method which would...