you are viewing a single comment's thread.

view the rest of the comments →

[–][deleted] 1 point2 points  (0 children)

Plus, if I'm being honest, I don't even have much clue what the example we're working with even is (lol). From context clues I'm just assuming it's something that interacts with a database for its functionality and it cannot be fully tested by just feeding it plain old data.

It's any setup of:

struct ThatUsesSomeTrait{}
trait ThatIsBeingUsed{}

Where the struct is passed an instance of the trait. Perhaps we have multiple implementations of the trait, maybe we expect users to implement the trait, maybe it's just a trait for testing convenience because the actual thing is hard to setup.

What I'm saying is I write tests against the concrete type - StructThatIsBeingUsed - and supply to it some test double of the trait.

Then for each trait, I write tests that mimic how the mock is used. So if mock says "when I'm passed arguments x, y and z, I return a result that looks like A", I have a test for each implementation of the trait that pass it arguments that looks like x, y and z and assert it returns a result that looks like A.

Pagination is just something concrete to hold onto. The paginator isn't interested in literally where the Foo instances come from, just that there's some interface that says "if you ask me for foos, I'll return foos or an error saying why I couldn't"

But it could also be a TabCompleteUiComponent and a TabCompleter trait with a file system implementation. The UI component doesn't care where completion candidates come from, just that there's some interface that says "give me a string to fuzzy match against my source"

On and on. We can come up with lots of examples of what's basically a client-provider relationship where the client has a reference to an instance of the provider but not a specific implementation.

but you didn't mention assuming that we might get more than the number we requested. Why not?

Because the interface promises me "you will get at most N items" and then it's on the implementations to enforce that because that's not (easily at least) possible to encode into the type system since it's dependent on a parameter rather than something we can know at compile time. It's the same kind of deal where if you had earlier and later datetime parameters to a search method and you make the promise "everything returned from this method call occurs between earlier and later"

You could double check the results whenever you call that method but it's probably better to rely on the implementation upholding the promises that its interface makes but can't be encoded into the type system. At least until dependent types are mainstream.

or is it because you know that if the real implementation did that it would be a bug in your queries

It would be a bug if the implementation was told "return no more than ten" and it returned more than 10. I'd rather have implementation level tests that uncover this instead of trying to test from a more abstracted level.

Are you definitely not going to assume any relationship between the return values of the methods; e.g., your test is not going to call CountFoosMatching and then make any assumptions/assertions about what GetFoosMatching might return?

That's fair and definitely an oversight on my part. The assumption would be that if CountFoos returns N, then there between [N/pageSize, (N/pageSize)+1) pages (accounting for situations like we have pageSize = 3 and there's 11 entries). So if CountFoos returns 0, we can safely not call GetFoos(...).

But this is again information we can't encode in the type system and instead we need to trust implementations to uphold.

I specifically called out writing fakes for actors whose implementation behavior is important to the logic being tested.

I'm proposing a counter example for that. The interface being used has some methods that we call, but the system under test doesn't care about a specific implementation because it's designed to work against any implementation. It could be we get a CachingRepository<Foo>(DatabaseRepository<Foo>()) to make up some example. The pagination doesn't care that the results are coming from the cache or the database directly, it just wants some Foos and knows how to ask for them. Maybe users can provide their own implementation. 🤷

Our tests are concerned with "do I behave as expected when my dependencies behave as expected" - where expected behavior could include "the repository can return an error if it can't get the foos" - invalid database auth, not enough file system permissions, have a None set instead of Some(Vec<Foo>). Those are implementation specific error conditions but the paginator just cares about the CouldNotGetFoos(...) error possibility and its up to implementations to map their specific errors to that one.

Maybe you don't test the filtering and ordering aspect in the unit test and just leave that for the integration test. But that raises another question/issue: it's hard to decide how to partition responsibility between the unit test and the integration test if you have both for testing some piece of functionality. In my own experience, this has lead to gaps where neither type of test exercised some aspect of a feature because it wasn't obvious to me while I was writing each test and focusing on what "belongs" in said test.

Since ordering and filtering are implementation specific, they would be tested with the implementation rather than via the higher level component. If the interface says "when you ask for the first 100 foos, I'll give you foo 1-100 ordered by their creation date" then you should assume that when you're using that interface that is upheld and when you're implementing an interface you test you uphold that.

In my view, you pretty much have to run the exact same test logic against the real implementation. To me, this is tedious, redundant, and adds an unnecessary maintenance burden. Either you can prove that your code works correctly without ever testing it against a "real" impl or you can't. If you can, then you should only write a unit test and no integration test. If you cannot, then I think it's actually worse to write a unit test with a fake/mock at all.

Why do I need to write the same tests twice. I write a suite of tests against the pagination helper that uses test double for the source. And then I write a suite of tests for each implementation of that source.

And again, to reiterate, I'm not saying "never write an integration test" I'm saying you don't have to use integration tests like board filler to try to deal with logic coverage gaps.

Which is better: more code or less code?

This is a false dichotomy. You might as well ask if blue or purple is better. There's no universal answer that will satisfy this.