I am mostly writing this article as I gave a talk on testing, at PHPhants. I made a blind guess that being too technical wouldn't work, and I was correct. This is the more technical version, just focussing on the low level testing.
The first thing to test is obviously the requirements. This isn't a unit-test, so omitted from this article, but please try the BDD article ~ when I have written it. This article is describing a list of 'whys', but written from my view point, to be more readable.
In good PHP codebases, you are likely to be using DI, as its easier to manage a project. It makes testing a lot easier, as you can inject static stubs. For current practice (i.e. using Pimple and composer), I make some tests (but not all) using the DI and the config file (e.g. services.yml in Symfony) which allows you to test the config file. To repeat, for most tests, having isolated unit tests encourages stability and test speed.
There are measures about isolation 1 [XXX], and use of frameworks. If you are writing inside a good OO framework, you will find it hard to be completely isolated. Assuming the framework is well maintained, using small bits of the framework should a manageable risk. Generally you want to avoid using non-maintained frameworks. For expanding projects, there should be an expanding test population. This means you are likely to be duplicating things in your tests. Refactoring at this point is valuable, for general code hygiene. This follows normal code practice.
If your objects emit primitives (e.g. ints, strings in some languages but not in others), it is easier to test. This is considered poor OO by many OO people 2 3 4. There are reasons for both positions. Ensuring that types are objects is easier to manage in PHP, due to limitations in the interpreter 5 6. It is easier to extend functionality when using objects than primitives. EDIT: Obviously the use of PHP7 allows more type hinting capacity, and return type hinting.
Short OO code uses exceptions, as it reduces the code volume needed. People from a C background don't like them (its a lngjmp, dressed up to look pretty in a high level language 7, and keeping the extra stack data offends them). I like Exceptions, I think they are the best feature added in PHP5. As this isn't about C, we are already paying for exceptions ~ the stack frame references ~ so use them. I am paid for solutions, rather than saying “hangon, I'm still typing the error handling code...". Exceptions should be tested in unit tests. It is important to test that you get the semantically correct exception. Likewise the units should be written to encode this data. As far as Exceptions are a lngjmp, you can code simpler, so there will be less bugs. Again, a good idea. Exceptions can be integrated into your error response logic, if for example each Exception in a web platform included the name of the correct error template.
I think the best balance on technology is to have dense tests, that is to have a high number of assertions per method. It is obviously good practice to only have one unit (e.g. a method call) per test, but this made need may several assertions. If you have a function that returns an array, you should check that the call result is an array, it has content, and hopefully the content is as expected. If the call has any exceptions that is at last one more. The array content step may not be possible, but in total that is at least three assertions. A very factored code base will be describable with less assertions per method, as the methods will be doing less each. But will need more methods and more unit test object for each. This trend leads to functional programming. The amount of factoring depends on available time budget, and complexity of the problem space. My first approach to working out what methods are necessary is to do traditional object and responsibility mappings. Method calls should be seen as messages to the object.
Practically, if you want to TDD, the most important feature of low-level unit tests is that they are fast. You are supposed to run these quite often, and waiting for more than 30s will kill this idea. You can run unit tests whilst still writing the code, as long as you manage writing files correctly. This is still more cognitively difficult, unless you are writing slowly. There are a number of other people who have done talks or written on this subject. I mention @everset, due to the work he did on Behat (try 8, I can't find the presentation notes from Feb 2015 that he did on high speed tests, but with the tests not being parallel). Unit-tests should be simple code, with a high level of isolation to make them more reliable. Isolation in terms of other libraries, for example, the ORM. It is good to hardcode things in unit tests 9 10 11, as this makes them smaller and faster. Avoiding code duplication is valuable, as it makes them faster to refactor. Those statements are deduceable, and engineering; so I haven't found a reference.
When I'm writing code properly, I use alot of optional configuration. This means the code is easier to reuse. It should be optional with a sensible default (via the config class, preferably). Reading the config file should tell you everything the class does ~ as the config is editable, it should be annotated/documented. The logical state space should be tested for the object. This will need to be injected from the Config. This can normally be assembled quickly, as Config classes either Mock or can be given test data to load (a fixture). This is fast to test. Some people prefer to keep the code simpler by just generating many minimal objects (and no config). This means that everyone using the code has to be a developer. The person using the code writes Factory that chooses to import the desired library class. If you are doing design-by-contract style development this means you need to write many tests to demonstrate that the contract is complied to.
Some developers use lots of Mock objects. There are a couple of different PHP Mock libraries. For example 12 13 14. With newer versions of PHP, Mocks will generate a child class of the Mocked class; so should work with all the type hinting on params. There are a couple of things to think about when choosing a Mock library. The developers time it take to build the Mock object; and the execution time, if you want to run these tests really frequently (remember Mock Factories returning Mock Services, and therefore Mock Entities is a long chain of fake code). For quite a lot of the code that I write, the class that I would be mocking is a system class from the platform/ framework. I am not sure the value in mocking these; as they already exist, and are asserted to be correct.
Business level people are more interested in API tests than unit tests. They see the value in these tests more. Some platforms for doing these are 15, 16, 17. API tests can be combined with BDD. API tests are minimal code coverage, but if the problem space is really simple, this may be sufficient.