If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeNow that we're building all these dinosaurs... we need a place to keep them! Right now they're running free! Terrorizing the guests! Eating all the ice cream! We need an Enclosure
class that will hold a collection of dinosaurs.
You guys know the drill, start with the test! Create EnclosureTest
.
We don't want any surprise dinosaurs inside!
Create the new Enclosure()
and then check that $this->assertCount(0)
matches $enclosure->getDinosaurs()
.
... lines 1 - 7 | |
class EnclosureTest extends TestCase | |
{ | |
public function testItHasNoDinosaursByDefault() | |
{ | |
$enclosure = new Enclosure(); | |
$this->assertCount(0, $enclosure->getDinosaurs()); | |
} | |
} |
Ok, good start! Next, inside Entity
, create Enclosure
. This will eventually be a Doctrine entity, but don't worry about the annotations yet. Add a private $dinosaurs
property. And, like normal, add public function __construct()
so that we can initialize that to a new ArrayCollection
.
... lines 1 - 8 | |
class Enclosure | |
{ | |
... lines 11 - 13 | |
private $dinosaurs; | |
... line 15 | |
public function __construct() | |
{ | |
$this->dinosaurs = new ArrayCollection(); | |
} | |
... lines 20 - 24 | |
} |
Back on the property, I'll add @var Collection
. That's the interface that ArrayCollection
implements.
... lines 1 - 8 | |
class Enclosure | |
{ | |
/** | |
* @var Collection | |
*/ | |
private $dinosaurs; | |
... lines 15 - 24 | |
} |
Now that the class exists, go back to the test and add the use
statement. Oh... and PhpStorm doesn't like my assertCount()
method... because I forgot to extend TestCase
!
... lines 1 - 4 | |
use AppBundle\Entity\Enclosure; | |
... lines 6 - 7 | |
class EnclosureTest extends TestCase | |
{ | |
... lines 10 - 15 | |
} |
If we run the test now, it - of course - fails:
./vendor/bin/phpunit
In Enclosure
, finish the code by adding getDinosaurs()
, which should return a Collection
. Summon the tests!
... lines 1 - 8 | |
class Enclosure | |
{ | |
... lines 11 - 20 | |
public function getDinosaurs(): Collection | |
{ | |
return $this->dinosaurs; | |
} | |
} |
./vendor/bin/phpunit
We are green! I know, this is simple so far... but stay tuned.
Before we keep going, since the tests are green, let's add the missing Doctrine annotations. With my cursor inside Enclosure
, I'll go to the Code->Generate menu - or Command+N on a mac - and select "ORM Class". That's just a shortcut to add the annotations above the class.
... lines 1 - 8 | |
/** | |
* @ORM\Entity | |
* @ORM\Table(name="enclosures") | |
*/ | |
class Enclosure | |
{ | |
... lines 15 - 29 | |
} |
Now, above the $dinosaurs
property, use @ORM\OneToMany
with targetEntity="Dinosaur"
, mappedBy="enclosure"
- we'll add that property in a moment - and cascade={"persist"}
.
... lines 1 - 8 | |
/** | |
* @ORM\Entity | |
* @ORM\Table(name="enclosures") | |
*/ | |
class Enclosure | |
{ | |
/** | |
* @var Collection | |
* @ORM\OneToMany(targetEntity="AppBundle\Entity\Dinosaur", mappedBy="enclosure", cascade={"persist"}) | |
*/ | |
private $dinosaurs; | |
... lines 20 - 29 | |
} |
In Dinosaur
, add the other side: private $enclosure
with @ORM\ManyToOne
. Point back to the Enclosure
class with inversedBy="dinosaurs"
.
... lines 1 - 10 | |
class Dinosaur | |
{ | |
... lines 13 - 32 | |
/** | |
* @var Enclosure | |
* @ORM\ManyToOne(targetEntity="AppBundle\Entity\Enclosure", inversedBy="dinosaurs") | |
*/ | |
private $enclosure; | |
... lines 38 - 73 | |
} |
That should not have broken anything... but run the tests to be sure!
./vendor/bin/phpunit
Testing that the enclosure starts empty is great... but we need a way to add dinosaurs! Create a new method: testItAddsDinosaurs()
. Then, instantiate a new Enclosure()
object.
... lines 1 - 8 | |
class EnclosureTest extends TestCase | |
{ | |
... lines 11 - 17 | |
public function testItAddsDinosaurs() | |
{ | |
$enclosure = new Enclosure(); | |
... lines 21 - 25 | |
} | |
} |
Design phase! How should we allow dinosaurs to be added to an Enclosure
? Maybe... an addDinosaur()
method. Brilliant! $enclosure->addDinosaur(new Dinosaur())
.
... lines 1 - 8 | |
class EnclosureTest extends TestCase | |
{ | |
... lines 11 - 17 | |
public function testItAddsDinosaurs() | |
{ | |
... lines 20 - 21 | |
$enclosure->addDinosaur(new Dinosaur()); | |
... lines 23 - 25 | |
} | |
} |
And this is where things get interesting. For the first time, in order to test one class - Enclosure
- we need an object of a different class - Dinosaur
. A unit test is supposed to test one class in complete isolation from all other classes. We want to test the logic of Enclosure, not Dinosaur
.
This is why mocking exists. With mocking, instead of instantiating and passing real objects - like a real Dinosaur
object - you create a "fake" object that looks like a Dinosaur
, but isn't. As you'll see in a few minutes, a mock object gives you a lot of control.
So... should we mock this Dinosaur
object? Actually... no. I know we haven't even seen mocking yet, but let me give you a general rule to follow:
When you're testing an object (like
Enclosure
) and this requires you to create an object of a different class (likeDinosaur
), only mock this object if it is a service. Mock services, but don't mock simple model objects.
Let me say it a different way: if you're organizing your code well, then all classes will fall into one of two types. The first type - a model class - is a class whose job is basically to hold data... but not do much work. Our entities are model classes. The second type - a service class - is a class whose main job is to do work, but it doesn't hold much data, other than maybe some configuration. DinosaurFactory
is a service class.
As a rule, you will want to mock service classes, but you do not need to mock model classes. Why not? Well, you can... but usually it's overkill. Since model classes tend to be simple and just hold data, it's easy enough to create those objects and set their data to whatever you want.
If this does not make sense yet, don't worry. We're going to talk about mocking very soon.
Let's add one more dinosaur to the enclosure. And then check that $this->assertCount(2)
equals $enclosure->getDinosaurs()
.
... lines 1 - 8 | |
class EnclosureTest extends TestCase | |
{ | |
... lines 11 - 17 | |
public function testItAddsDinosaurs() | |
{ | |
... lines 20 - 21 | |
$enclosure->addDinosaur(new Dinosaur()); | |
$enclosure->addDinosaur(new Dinosaur()); | |
$this->assertCount(2, $enclosure->getDinosaurs()); | |
} | |
} |
Try the test!
./vendor/bin/phpunit
Of course, it fails due to the missing method. Open Enclosure
and create public function addDinosaur()
with a Dinosaur
argument. When you finish, try the tests again:
... lines 1 - 12 | |
class Enclosure | |
{ | |
... lines 15 - 30 | |
public function addDinosaur(Dinosaur $dinosaur) | |
{ | |
$this->dinosaurs[] = $dinosaur; | |
} | |
} |
./vendor/bin/phpunit
Oh, and one last thing! Instead of $this->assertCount(0)
, you can use $this->assertEmpty()
... which just sounds cooler. It works the same.
... lines 1 - 8 | |
class EnclosureTest extends TestCase | |
{ | |
public function testItHasNoDinosaursByDefault() | |
{ | |
... lines 13 - 14 | |
$this->assertEmpty($enclosure->getDinosaurs()); | |
} | |
... lines 17 - 26 | |
} |
Ok, now let's talk exceptions!
Hey Lijana Z.!
Yea, we try to make things as realistic as possible, but there are always "uglier" situations out in the "wild". In your situation, if you have legacy code that needs refactoring, then this likely means that it does not have any tests yet. So, TDD is a bit different in this case. Really, you have a few options: (1) trying to add some tests to the existing code to make sure you don't break anything and then doing TDD with the new feature change or (2) trying to extract the complex part into a new class and do TDD there, leaving all of the other, old logic in its original location.
It's hard to describe, but *when* I do TDD (which, as you know is far from always), it is in situations that feel very natural. What I mean is, it is in situations where the business logic requires a class/function that has various input and various output. So, I naturally *want* to do TDD in these situations: where I can test all the input / output before writing the code. In other situations, where the class/function I need to write does *not* have a lot of input/output variations (e.g. maybe a function that does 1 thing, saves something to a database, then does 1 other thing), I often won't test it directly. Honestly, a lot of our tests are ultimately functional tests, because the individual units are not that complex, but we want to make sure the feature works. We more often do TDD with functional tests. For legacy code, this would mean first writing a functional test to see that the existing feature works, then write a new functional test for the new feature, then coding that feature. TDD... but on the functional test level.
Cheers!
Hey Lijana Z.
If you constantly work with wild legacy code, I recommend you this book: https://www.amazon.com/Work...
It's pretty good :)
Cheers!
I don't now, but I believe in future probably everyone need to work with legacy code over their carreers. Unless you are chnaging jobs once you get legacy project :)
haha, yeah, we all will work on a legacy project eventually, but the intention is to be able to start new projects where the base code will not rot, or to improve old projects little by little.
Once again, read that book, you will find a lot of tricks when working with legacy code :)
When we test the functionality to add dinos into our enclosure, no parameters are passed into the Dinosaur's constructor...but it has parameters!
How didn't the tests fail when they were executed?🤔
Hey Abelardo
You're watching closely ;) - That's because both arguments of the Dinosaur's constructor are optional, so if you don't pass any, it will use the default values instead
Cheers!
Yes, I watched the video where both parameters were initialized by default with "Unknown" and false respectively.
Thanks!
Best regards.
I'm going to rewatch the prior videos because I didn't realize when those parameters were set as optionals.
Thanks for your reply.
Cheers!
About mocking model-objects (in my case Doctrine entities): The service which I'm testing makes use of the auto-generated ID of the provided entity. However, the Doctrine entity does not have a setId()-method, so setting an ID just for testing is not possible. That's a problem, because when I feed the entity to my service, the service can't read the entity ID and explodes...
Would you mock the entity, in such a case? Or would you apply some other best practice, here? And if I should create a mock, is it possible to create a mock based on an instance of my entity-class, so I don't have to put a lot of ->willReturn()'s on my mock?
Hey Thijs-jan V.
That's a good question and there some options you can take
1) Just add a setId()
method to the entity but add a PHPDoc @internal flag, so you indicate that such method is only for testing purposes
2) Use Reflection to set whatever value you want on the id
property
3) You can mock an entity class, although it's not recommended, it's still valid when you have a complex situation and using a mock just make life easier
4) Refactor your code so it works with the ID value instead of the entity object
You can pick whichever fits better your needs. Cheers!
it seems like something has changed with a newer library versions, code below (EnclosureTest, line 25) does not pass the test:
<br />$this->assertEquals(2, $enclosure->getDinosaurs());<br />
with the following error:
<i>1) Tests\AppBundle\Entity\EnclosureTest::testItAddsDinosaurs Doctrine\Common\Collections\ArrayCollection Object (...) does not match expected type "integer".
</i>
although it works with explicit integer value:<br />$this->assertEquals(2, $enclosure->getDinosaurs()->count());<br />
Hey _zippp
Looks like your line 25 is a little bit different. In course code Line 25 is:
$this->assertCount(2, $enclosure->getDinosaurs());
It's assertCount()
not assertEquals()
, so probably code you got some outdated code somehow. You can just fix this line, or download course code again to be sure that everything is in sync.
Cheers!
There is a mistake in the code of this chapter: when we make annotations for enclosure and dinosaurs in the entities Dinosaur entity is covered two times.
Hey toporovvv
What annotations are you talking about? About the relationship between Dinosaur and Enclosures? If that's the case, we are just declaring the inverse side of the relationship.
Have a nice day.
I'm talking about the code below this text:
"Now, above the $dinosaurs property, use @ORM\OneToMany with targetEntity="Dinosaur", mappedBy="enclosure" - we'll add that property in a moment - and cascade={"persist"}."
There should be Enclosure entity, but there is a Dinosaur.
Then, below this code and after the text block - "In Dinosaur, add the other side: private $enclosure with @ORM\ManyToOne. Point back to the Enclosure class with inversedBy="dinosaurs" - exactly the same code as above.
Interesting about mocking entities. I had heard that we should not mock it, but when entities used to have some function which is not getter or setter, I felt like I need to mock that. So I learned that we can mock them. I used to mock them, but I thought I was doing poor tests and code was poor in entiy because it was having function other than getter and setter. I thought problem is that it is not good for tests if entity has other than getter and setter function.
Hey Coder,
It depends, but I think as far as your functions in entities simple and useful - it's ok to have them there. And yes, you need to think about mocking entities only when you really need it, i.e. when it's much simpler to mock entity instead of setting proper data to it, but in most cases you probably don't need to do that ;)
Cheers!
// composer.json
{
"require": {
"php": "^7.0, <7.4",
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/doctrine-bundle": "^1.6", // 1.10.3
"doctrine/orm": "^2.5", // v2.7.2
"incenteev/composer-parameter-handler": "^2.0", // v2.1.2
"sensio/distribution-bundle": "^5.0.19", // v5.0.21
"sensio/framework-extra-bundle": "^3.0.2", // v3.0.28
"symfony/monolog-bundle": "^3.1.0", // v3.1.2
"symfony/polyfill-apcu": "^1.0", // v1.6.0
"symfony/swiftmailer-bundle": "^2.3.10", // v2.6.7
"symfony/symfony": "3.3.*", // v3.3.13
"twig/twig": "^1.0||^2.0" // v2.4.4
},
"require-dev": {
"doctrine/data-fixtures": "^1.3", // 1.3.3
"doctrine/doctrine-fixtures-bundle": "^2.3", // v2.4.1
"liip/functional-test-bundle": "^1.8", // 1.8.0
"phpunit/phpunit": "^6.3", // 6.5.2
"sensio/generator-bundle": "^3.0", // v3.1.6
"symfony/phpunit-bridge": "^3.0" // v3.4.30
}
}
Btw so far TDD looks easy with those simple code examlples. But to get a good tutorial, I think you should to take some class from real complex business project and add or modify some feature. I would like to see how its done there. And take some bit more legacy code which needs a bit of refactoring. Thats where I struggle with TDD.