"why are "unit tests" considered so important when they don't really
test your application"
Perhaps an analogy will help; an isolated unit test is like SpaceX engineers testing one of their Raptor engines without hooking it up to an actual rocket.
Instead, they hook it up to test equipment which allows them to test the behavior of the engine in isolation, and allows them fine control over the experimental conditions.
The engine is the unit under test, and the supporting equipment is, quite literally, a mockup of the other parts of the rocket that connect to the engine. It doesn't do everything that the rocket does. It does, however, have to do everything that's necessary for the purposes of the test, and it does have to interface with the engine in the same way a rocket would (it has to have the same kinds of connectors and/or adapters to hook up to it in the same way).
Note that the engine is not actually doing the job that it's really supposed to do (put a rocket into space, and help land it safely), but they can still test, to a significant extent, if they made it right (if it connects properly, responds to controls, generates the right kinds of outputs, the right amount of thrust, etc).
A unit test should be very much like that. You "poke" the function/class/component ("unit") under test in different ways, and you check if, externally, it appears to adhere to the logic in the way you expect it to.
So, in that light, the test should be a stand-in for the actual production code that's going to be interacting with your unit; it's a tightly controlled representation of the immediate "environment" of the unit. If such tests are hard to write, then your actual unit will be difficult to use and/or reuse in production. If the tests fail, then the code using your unit in production will likely fail too. The idea is that both the actual client code and the tests rely on the same things. Mocks help you control the "environmental" conditions and "measure" the way the unit responds to various "pokes". They are test-specific, and you configure them to do exactly what you need them to do for that specific test, and nothing else.
SpaceX engineers - and we, software developers - do it because, if done properly, it's much cheaper and faster to test this way. You don't get all the info, but you get a lot of fundamental info that gives you confidence that parts of your system do what they are supposed to do at the component level.
This means that you can run experiments more frequently, and that you can get feedback much more quickly (= unit tests should be lightweight and super-fast). In our case, the feedback is (1) on the correctness of the behavioral contracts that the component is supposed to adhere to, and (2) on how usable the interface (the API) of the component is.
You get quality by failing fast, and failing as many times as it takes you to figure out the best way to do it. That doesn't mean it's easy coming up with good tests, or with good design for the unit, and the system itself.
Actually attempting to fly the rocket is an integration test (they probably have levels of integration experiments between testing the engine in isolation and going to the launch pad, but let's go with this analogy). What you're trying to do there is look for any unwanted behavior emerging from the interaction of all the parts.
This test is expensive, and in comparison, it's not at all quick and easy to set up. Countdown: 3, 2, 1. Wooooshhhh! We have a lift of! ... A few seconds later, the rocket blows up. This is a good thing. It allows you to catch problems early, learn about them, and try and create a more robust system.
In some sense, SpaceX was able to do the amazing things they've done because they could afford blowing up a bunch of rockets. Heck, they blew up a rocket on purpose to test the abort mechanism.
A bad thing would be if a rocket blows up later on - you know, when in production.
"I feel like missing in between these are tests of 1 dependency
(whether these are normally called "unit tests" or "integration tests"
I'm not sure.)"
There's a little bit of a blurred line there. In one of the preceding paragraphs, I used the phrase: 'You "poke" the function/class/component ("unit")'. A unit is what you define it to be - and it doesn't have to be the same thing everywhere throughout the same project. You're looking for something that you can call a "component" that has a narrowly defined job, with well defined behavior that you can easily and quickly test in isolation (that is, without dependencies external to the "component", as you defined it).
A "component" could be a single function, or an object, or a small bundle of interacting objects. Such a bundle can create a couple of objects internally - your tests (for that bundle) should not know about them (for the same reasons actual client code in production should not know about them). Such a bundle can also have one or more external dependencies that you can inject at construction time - these are part of its client-facing API - and your tests should mock them.
So, thinking in terms of the number of dependencies is not a good way to differentiate unit tests from integration tests.
Here's a set of guidelines coming form Michael Feathers, the author of Working Effectively with Legacy Code (emphasis mine, [comments] mine):
A test is not a unit test if:
- It talks to the database
- It communicates across the network
- It touches the file system
- It can't run at the same time as any of your other unit tests [meaning, tests aren't independent of each other]
- You have to do special things to your environment (such as editing config files) to run it.
Tests that do these things aren't bad. Often they are worth writing,
and they can be written in a unit test harness. However, it is
important to be able to separate them from true unit tests so that we
can keep a set of tests that we can run fast whenever we make our
I want to emphasize that all of this is not to put the tests on a pedestal. It's not about tests, it's about the design of the code under test. It's about applying your design skills. Tests are a tool that helps with that. Here's another thing I want to quote Michael Feathers on:
"Solving design problems solves testing problems1. Making the code testable2 doesn't necessarily make the design better."
1 Allows for testability.
2 E.g. making a method public just for the sake of testing.
Tests starts falling into the integration test territory when you stop testing if a component adheres to well-defined behaviors associated with it by design, and start testing to see if "stuff works together". Note that integration tests, generally speaking, can have varying scope (integration of a couple of components, or a larger part of the system, or the entire system). The way you write these tests and gather data might be a bit different compared to what you'd do in unit tests (e.g. you might start using different tools as the tests grow in scope, a QA person might be involved, etc). Also note that people in the industry use differing definitions for what constitutes an integration test - it's not a sharply defined term industry-wide.
According to the test pyramid, you'd have a smaller number of these tests compared to unit tests.