Scott Keck-Warren gives a presentation on getting started with test-driven development (TDD). He discusses what TDD is, the five phases of the TDD process, and why it is beneficial. He also covers how to use a testing framework like PHPUnit, what code coverage is, and some common pitfalls to avoid like neglecting to run tests or creating tests that are too large or trivial. The presentation aims to provide developers with the essential information needed to understand and implement TDD.
16. Agenda
1. What Is Test-Driven Development
2. Why You Should Be Using Test-Driven
Development
3. How To Do Test-Driven Development
4. What is Code Coverage?
5. Common Gotchas
18. What is Test-Driven Development?
• Abbreviated TDD
• Test-first software development process
• Create suite of automated tests
19. What is Test-Driven Development?
• Short cycles of 5 phases
• Less Minute
• Dozens/hundreds of cycles each day
20. What is Test-Driven Development?
• “Rediscovered” by Kent Beck
• “Test Driven Development: By Example”
21. What is Test-Driven Development?
• “Best” for greenfield
• Works well for brownfield
• Check out “Working Effectively with Legacy
Code” by Michael Feathers for brownfield
43. • A PHP class groups similar tests together
• Ideally a test class for each application class with logic
• A test class for common initial conditions
• Each class ends with “Test”
150. Creating Too Many Tests At Once
• Get that “Success” dopamine hit
• Make sure we don’t get two far a head of ourselves
• Prevent unnecessary refactoring
157. What You Need to Know
• TDD: test-first software development process
• Five short phases
• Each of the phases happens very quickly
• Don’t neglect to refactor
158. What You Need to Know
Add A
New Test
Run All
Tests to
See
Failures
Make a
change
Run Tests
to See
Success
Refactor
159. What You Need to Know
• Use a testing framework like PHPUnit/Pest
• Write clear and concise tests
• Code coverage determines if our code has enough tests
160. What You Need to Know
• Avoid
• Neglecting to Run Tests
• Creating Too Many Tests At Once
• Creating Large Tests
• Creating Trivial Tests
As developers, we know that change breaks production
No mater how small the change
I’ve fixed a spelling error in a string and created a bug
Whose heard this one before
<slide>
That’s way my mantra because I don’t want to have to boot my work computer up on Sunday
Maintained a module to set professional development goals
Process had seven steps
Alternated between supervisor and direct
Two “modes”
Testing took a minimum of 10 minutes
Because there we so many moving parts:
<slide>
Someone would say can we add a new feature
I would say yes very reluctantly and then tell them 2 days minimum
Confidence that I could make changes
Not break anything
Hard to do with all the manual testing
Cloud image in the back
TDD gave me that confidence
For those of you who haven’t met me my name is …
Professional PHP Developer for 15 years
// team lead/CTO role for 10 of those 15
Currently Director of Technology at WeCare Connect
Survey solutions to improve employee and resident retention at skilled nursing facilities
Use PHP for our backend
Also …
Create Educational Videos on PHPArchitect YouTube
Discuss topics helpful for PHP developers
If you want more content like this session follow me on social media and subscriber to our channel
Found at youtube.com/c/phparch
Could spend hours discussing automated testing with you all. It’s one of my favorite PHP adjacent topics but we only have 50 minutes.
My goals are to always have you leave one of my sessions with something you can use the next day you’re going to work and how you can implement this with your team
TDD is a test-first software development process that uses short development cycles to write very specific test cases and then modify our code so the tests pass.
TDD consists of five phases that we will repeat as we modify our code. Each of the phases happens very quickly and we might go through all five phases in less than a minute.
TDD was "rediscovered" by Kent Beck while working on the SmallTalk language and has been documented extensively in his book "Test Driven Development: By Example". The book is an excellent primer for working with TDD as it works through several examples of how to use TDD and also explains some techniques for improving code.
One of few physical books I have
While TDD works best for greenfield applications, it's a benefit to anyone working on a brownfield application as well.
Brownfield applications tend to require a little more finesse to get them set up for automated testing so don't get discouraged if it's frustrating at first.
“Working Effectively with Legacy Code” by Michael Feathers is a helpful book in this regard
TDD consists of 5 SHORT phases
< ten new lines of code being modified. If we find ourselves doing more than that we're working on too large a change and we need to break it into smaller pieces.
The first thing we're going to do is write a failing test. We'll use this failing test to help determine when we've achieved our expected functionality.
It's important that the test is succinct and that it's looking at how a **single** change will affect our code.
Goal: how does a single change affect our code
2. Run all tests and see the new one fail
In this step, we're going to run the test to make sure our test fails before we move on to the next phase. It's very easy to write a test that doesn't fail so we **always** run our test to verify it's failing before moving to the next phase.
If it’s not failing we don’t know if our change actually did anything
As a small aside, the wording for this phase says "run all the tests" but as our test suite (a collection of tests) grows this will take an unproductively large amount of time. Current code base takes 25 minutes to run whole suite.
We'll want to short circuit this and only run the test file or just our new test. Many IDEs can run a single file or even a single test and it's worth spending time figuring out how to get this working as it will make us more productive.
Some are built in like PHP Storm but some require extensions like VS Code.
3. Make a little change
Now our goal is to change the smallest amount of code possible to get that test to pass.
We don't want to change any more than is necessary because that extra bit of change wasn't made using TDD and is potentially not tested. We don't need perfect code in this phase we just need code that makes the test pass. It's very easy to get caught up in making sure everything is perfect but that's not the goal here. Perfect comes later.
4. Run all tests and see them all succeed
Now that we've made our change we can run our test and see that it passes new test and any other tests.
If it doesn't then we just jump back to phase #3 and keep making small changes until it does.
5. Refactor to remove duplication
Now that we have our tests passing we're going to take a break and inspect both our test code and our code under test to see where we can make changes so it's easier for future developers to read, understand, and maintain.
This is called Refactoring and it’s: restructure code so as to improve operation without altering functionality.
We're using TDD so changes are painless because we can quickly run our unit tests again to make sure we didn't make a mistake.
Other things we should look for as we're doing this process:
Are the method/class/variables easy to read?
Do they express intent? -> We can name a function getTheValue but it doesn’t explain what fnt does and will cause confusion
Will future me understand them? A week? A Year? If I can’t how can anyone else who wasn’t in my head space
Other things we should look for as we're doing this process:
Can we move logic into the superclass so other classes can use it?
Can we move logic into a shared trait? Favorite from of reuse is when we can apply same function to multiple trees of inheritance
Now that we've completed a single TDD cycle we can start back at the beginning with a new test.
If you google TDD There’s also a three phase version
Called Red, green, refactor
Red and green come from colored messages we get from our testing tool
Basically group phases 1 and 2 and 3 and 4.
I like 5 phases because it gives explicit steps
Now that we know the theory we need a way to execute
Two major options in PHP community
PHPUnit has been around for a long time
Pest is a great option and has a session tomorrow by the creator so go see that
Our examples uses PHPUnit to run our tests so we going to do a Quick intro on PHPUnit
Going to put out a video on my examples here in pest make sure you subscribe to the you tube channel
In case you don’t know
Testing Framework
Written by Sebastian Bergmann
All tests are written in PHP so we don’t need to learn anything new
Organization is easy
All test files are placed inside a tests folder at the root of the project
A test is a PHP class method
Each test function starts with “test”
or …
uses the @test annotation
This is a personal preference thing. I learned PHPUnit before annoation option so I default to prefixing tests with test. Also uses less vertical space which helpful for presentations
Might notice assert function calls
Each test function contains one or more asserts
Asserts tell PHPUnit to compare an expected value to the value we got from our codeEach test contains one or more asserts
Compare output values <click>
Compare expected value vs output <click>
Check for greater than or equal <click>
A PHP class groups similar tests together
Ideally a test class for each application class with logic. Don’t need tests for classes with no logic.
A test class for common initial conditions
Might have class for tests involving a standard user and another class for administrator users
Each class ends with “Test” super important and PHPunit won’t “autoload” them.
Command line tool
Super powerful
Run a single file
Use filter to reduce runs
Example anything that involves users
Our examples today mostly command line
Running using
./vendor/bin/phpunit tests/FileTest.php
Did a whole video on how awesome value objects are
Nee
Custom classes for each domain type in our application
Provide three things
Problem they solve is this
Have a function to recreate user
Need first, last, email
Later call the function
Made an Oops
<click>
Should be <comment order>
Did email first
Tricky kind of bug
Dare you to find this in code review
Now I can’t mess up calling this
And
Helper functions
I have this value object class to track a City
Only important thing is that we’re storing our information in $value property
Want to add more functionality using TDD
First thing check to see if a our city has no length
1. Add a new test
Start from the beginning assuming no tests for City
First step is to create the test file
<screen> is current file
Swap tests for src
And append test
That way can easily find tests for city class
Start with our blank file
Create class
Class name needs to match the file name -> new PHPunit doesn’t like mismatch
Extends PHPUnit\Framework\TestCase . TestCase class gives us asserts and setup logic we need
Create the test function. We’re going to name the function based on what we’re testing to it’s easier to understand when future us comes back to it.
Create Our Initial Conditions
Test our result from the function.
Calling our isEmpty function and asserting it’s returning true
Notice how small the actual test is. We're giving the test a very specific functionality to test and we're only asserting one thing. If we have more than one assert per test we run the risk of making it difficult to debug later when something breaks.
2. Run all tests and see the new one fail
Now we'll run PHPUnit to see that we do indeed get a failing test.
In this case, we haven't yet defined the method so we get an "undefined method" error.
Red message -> in an error state like we saw in red/green/refactor
3. Make a little change
To reiterate, our goal in this phase is to make the smallest change we can to allow our tests to pass.
Two options here: <click>
add in the obvious implementation
Obvious means is a few lines that we can’t possibility mess up the logic for
This case just call to mb_strlen
2 - Make the smallest possible change by returning true always.
It doesn't cover all the possible inputs but the goal in this step isn't to cover all the inputs it's to get our test to pass. We'll cover more inputs later.
We’re going to want to show another cycle of TDD so we’re going to just return true
4. Run all tests and see them all succeed
Now we run our test and verify that our test passes.
Notice green from red/green/refactor
5. Refactor to remove duplication
Our code currently doesn't contain any duplication but it's important not to get lazy and skip this step.
Our simple implementation of `isEmpty()` is going to be wrong most of the time because of its current implementation. Now we need to add another test that checks for other cases where the string isn't empty.
As a general rule, it's a good idea to have tests for normal input, the extremes of inputs (very large or very small), and spots where we can think of oddities happening obviously very domain specific
We’re going to check for a case where the string isn’t empty to add an addition testing point
1. Add a new test
Add a new test
What we have currently
Add in the opposite
2. Run all tests and see the new one fail
This time we get a failure assertion that true is false
3. Make a little change
Look at our original code
Could change to return false but that would cause other tests to fail
Back to the obvious implementation we had before
4. Run all tests and see them all succeed
5. Refactor to remove duplication
Again due to the simple nature of our example there isn't any duplication in our code at this point.
Starting to get the hang of this
Adding a test now to check for a string that isn’t empty.
Could do !isEmpty() but isNotEmpty is actually easier for us to process as programmers so it’s nice to have
Also contrived example
1. Add a new test
Again small
Again written so we know what’s going on quickly
2. Run all tests and see the new one fail
3. Make a little change
In this case instead of returning `false` and then creating another test so we can write the functionality by going through all the TDD steps, we're just going to trust ourselves and create the obvious implementation of the `isNotEmpty()` function.
4. Run all tests and see them all succeed
Oh good we didn’t make any mistakes
5. Refactor to remove duplication
Now here is where it gets interesting.
The last two times we've hit this step we haven't had anything to do but now look at our `isEmpty()` and `isNotEmpty()` functions.
We can see some minor duplication in the two calls to `mb_strlen($this->string)`. Now we just need to determine how we want to resolve this.
2 options: Option 1 is to extract that duplication into a new function.
Call it length and copy duplication into body
Extracting a new function is favorite refactor because it makes the code more readable and because we'll most likely need the same logic again.
Now The replace the calls with the new function
The second option is to realize that `isNotEmpty()` returns the boolean opposite of `isEmpty()` and just use it
If I hadn’t been working through this example just to get to this who cares example I think I would have done this automatically.
The first option gives us the best flexibility for future expansion but second less code. Always a fond of less code
Finally, we need to run our tests again to verify that no accidents crept into our code as we made these changes.
In our example, we worked out an example of tests where we only tested a single class.
This is know as a unit test.
Unit tests are exceptionally good for testing each piece of our application but what if we want to test how all of the units work together? For example, we may want to test a request against our API endpoints so we can make sure they meet our specs.
Integration tests allow us to do this.
But they come with a dark side
Integration tests tend to be slower because we’ll be initializing 100s of classes and might be interacting with a database which is generally the slowest part of the application stack.
Really going to depend on your application but Unit test might take tenth of a second and an integration might take a second. So it’s not a tenth of second vs 50 seconds
Because the integration tests are testing several components at once it may not be quickly apparent what caused the test to fail. For example, our integration test might hit a dashboard endpoint and one of the components fails because of a poorly written query. The result might just be that an error occurred and not tell us what the error was.
Personally feel they’re worth every bit of time spent on them. Every integration test that fails before it get’s to a customer is one less bug ticket. I use TDD to develop these tests and it can be a dream.
Several PHP frameworks provide facilities that allow tests to simulate an entire web request and validate the output.
This is a laravel example, request /categories page using an HTTP get request
Check to make sure we get a 200 based response code and not a redirect or error message.
I’m a fan of doing integration tests on any endpoint where we’re creating or updating data because they tend to be hard to test with unit testing.
two lines of code for huge amounts of increase in code coverage
The "total number of lines of code" only includes lines that are performing operations and not declarations and white space.
As an example, this class
Single test to see that we can initialize our class
Let’s see what our coverage is
Only two lines are executable
Only the assignment is actually run during this test
Calcaluation on the board to help us
<result>
Manual isn’t going to work because our code is always changing
Thankfully
Need to also have XDEBUG or another tool installed
SET mode to coverage
Spits out same result
< 75% -> hard to feel confident making changes
> 90% -> fragile, expensive tests
Personally my team targets 80%
Missing critical stuff we target 95%
Want to make this part of our
Running at the command line also is a little rough so I like to include it in my CI/CD runs
Like to use Codecov but there’s a million of these SaaS companies that do this
Spits out results in our PR
Also shows lines that aren’t being covered by a test
As the suite of tests grows it starts to become part of the maintenance overhead of our project. To that end, we'll need to make sure we prevent running into some of these common gotchas to make our lives easier.
More tests -> longer time to run those tests
Two projects one takes less than a minute to run tests <click>
The other takes 55 minutes <click>
Because of this, it's very easy to get to a point where so many of the tests are broken that nobody feels confident in them. So we just stop using them
Solution: setup Continuous Integration server like Jenkins, TravisCI, or CircleCI to automatically run all the tests and report back any errors. We can even set them up to prevent new code from reaching our servers if any of the tests fail.
Ideally, all of the automated tests would be run every time a new commit is created but we’ve really commonly combined them into a PR so
Used to work with someone would write all tests first
Two things would happen
He would get bogged down into the fact that he only had half of his tests passing
Hard to tell what just broke and what was still not done
As he developed he would realize his public interface didn’t do what was necessary so refectored bunch of tests
One test at a time allows us to <slide>
<slide>
Individual tests should aspire to only have a single assert function in each test. There's nothing in PHPUnit that prevents us from setting up a single test with 100 assertions but that is an indication that the test is doing too much.
We won't be able to quickly look at the test and see what we've done or how to fix it because the test is so large.
Small tests allow us to quickly determine why something has broken and quickly adjust to get it passing again.
We won't be able to quickly look at the test and see what we've done or how to fix it because the test is so large.
Small tests allow us to quickly determine why something has broken and quickly adjust to get it passing again.
Finally
One of the things new practitioners of TDD tend to do is to create tests for trivial functionality. For example, we'll see tests that are set up to test new getters and setters. These tests tend to not be beneficial to our test suite because trivial functions don't change frequently and because the trivial code is usually being run as part of another test.
1. Add a new test
2. Run all tests and see the new one fail
3. Make a little change
4. Run all tests and see them all succeed
5. Refactor to remove duplication