Hacker News new | ask | show | jobs
by ignorabilis 4024 days ago
Mocks, stubs, shims, etc. are usually just hiding the fact that the code to be tested is terrible, even in OOP languages.

Furthermore side effects have nothing to do with testing one's code. Why? Because you have to test your own logic, not I/O or something else. I/O is already tested by someone else. So you actually don't care where the data comes from - the filesystem or a hardcoded string - you care if the output is correct after the respective transformations are applied to the input.

And when you think about it testing shouldn't be that needed at all. In the OOP world, where state and identity are helplessly tied together and we are not working with values, but with references to values, tests might be a necessary evil.

In the land of Functional however writing tests shouldn't be needed, at least in theory. Why? Because if you write small composable functions you should be able to test them right in the REPL. Corner cases? You have to think about them right from the start. TDD forces you to do so - then why shouldn't you when you have the immense power of a REPL? Once a function is ready you are not supposed to change it much. If you do you should change the inner workings and not the input/output. If you need to change the i/o most probably you need a different function. But what is more important is that if you needed to change the i/o and had tests for this function you would actually need to change the tests as well, which is more work (double? triple?) and no added value.

5 comments

Once a function is ready you are not supposed to change it much. If you do you should change the inner workings and not the input/output.

This presupposes that you will always come up with an adequate design, and that you won't have to refactor this library of functions. For large enough systems, this is almost never the case. For sizeable systems in production, you will always come up against things you haven't thought of before.

Granted, proper factoring of your functions in the first place is going to help a lot. (Small composable functions.) However, if you claim that you can code such that you never ever have to change your mind about function signatures, never have to propagate changes across the system, even in large production systems, then you're either not seeing something, or you have some technique which you should be selling or basing a consulting practice around. (And/or, maybe I have something new to learn.)

EDIT: I have written projects in Clojure, including a multiplayer game server. This is the experience I'm basing this comment on.

In the previous post I started with in theory. What this means is that theoretically things can go like this and in practice they have gone for me until now (with Clojure, so +1 for you too :)). It doesn't mean that it will work for everybody or even for me in the future.

Then: Granted, proper factoring of your functions in the first place is going to help a lot. hence -> Once a function is ready you are not supposed to change it much. much not= at all.

And you are absolutely correct - eventually you will have to change some of your functions. And when you need to do so you should focus on the logic and take your time until you understand it completely. Only then refactor the funciton. So the emphasis should be on the developer completely understanding the task and not on the developer finding a cheap way to change the code and rely on tests to catch his errors.

Furthermore if you change the signature of a function, tests are just another place where you need to change the function as well. In practice the tests that you wrote are not doing you any good because you cannot test the reinvented function with the old set of tests.

Sure, tests might catch a few errors here and there at some point. But after all what is more time consuming - maintaining hundreds of thousands of tests or focusing on your single, small, composable function and your problem?

I am part of a team that develops a very large .NET (with C#) product with hundreds of projects in the solution. So far the primary use of tests is to be maintained. They caught a few bugs, true, but nothing that our QAs were not going to see anyway. In imperative OOP land tests are a necessary evil, but with time I started doubting even that thesis.

In the previous post I started with in theory.

One thing I've found is that what would help tremendously with large production projects results from epiphenomena in code, and is sometimes not what is sexy from a language design standpoint.

I am part of a team that develops a very large .NET (with C#) product with hundreds of projects in the solution. So far the primary use of tests is to be maintained. They caught a few bugs, true, but nothing that our QAs were not going to see anyway.

I was a fly on the wall when Extreme Programming was being formulated in Smalltalk. (Not part of the Chrysler C3 project, but I was working for a Smalltalk vendor and heard about what was going on.) Test Driven Development, or at least good Unit Test coverage, is essential for an agile style project in a language like Smalltalk. I'm not so sure unit tests are as essential for languages like C#. TDD does produce more testable code, but it has considerable "cultural overhead."

Two important questions to ask are: What aids refactoring? What stops refactoring? The answers to these questions are also different for large codebases as opposed to small ones. I have noticed that Swift's enums are tremendously useful in this regard, but they are not "sexy" so this doesn't get discussed very much.

> Two important questions to ask are: What aids refactoring? What stops refactoring? The answers to these questions are also different for large codebases as opposed to small ones.

Part of the point of thorough unit tests that really test all the external behaviors of all your classes (or functions, if you're doing FP) is that it can give you the courage to refactor. If all the tests run for this class, then I didn't change any external behavior of the class. If the whole project's unit tests all pass, then I can be highly confident that my change did not introduce any bugs.

Note well the condition, though: if the tests really test all the external behaviors of all the classes. For that to be true, you almost have start with TDD from the beginning of the project.

As long as there is some sort of logic tests can be written. The question is can we prevent writing tests at all by designing better our applications and by taking advantage of FP languages and tools like the REPL? And I believe at least in theory we can. In practice life happens so this theory is certainly not applicable to every case on the planet.

When things are decoupled refactoring is not that dangerous. If all pieces of your program are small, intelligible functions you can always just refactor a single, simple function and you should not need tests to rely on - all you have to do is make sure that this function is working like it worked before the refactoring (or better).

> I have noticed that Swift's enums are tremendously useful in this regard, but they are not "sexy" so this doesn't get discussed very much.

Not sexy to whom? Algebraic data types are quite sexy right now, I'd say.

I disagree that I/O can be totally decoupled. The API that I/O functions generally adhere to is too weak for your application: on error, what do you want to happen? This pushes "I/O" functionality back into your logic, and that requires testing.

In this specific example, if you write the first file, but fail the second, you might want to undo the write to the first file. Or write another file to say you failed. Or write to a log. These sorts of things are important, and mocks can be a good way to fake it.

You should be testing your own code and not catching exceptions. I/O will either return an error or the string you need. There is no third option. So put aside the I/O - concentrate on testing the string transformations and on testing the logic that handles the error.

Btw in functional languages catching exceptions is not idiomatic, it breaks the functional approach.

In this example you are not supposed to test undoing the file. Undoing (deleting) is what I/O does and this part is tested by someone else, somewhere else. You only need to test the arguments that are passed to that specific I/O function. If they are correct your program is correct. If they are hardcoded you shouldn't be testing them at all.

You may want to write a log. Again, test what you are about to pass to an I/O function. Don't test if the file is actually written. If you supply the I/O function with the correct arguments and the function fails to write the file, the issue is not in your code - search for it elsewhere.

I read recently (in another article here on HN) a quote from Adele Goldberg that in OO, "everything happens somewhere else". This was not meant as a compliment to the OO approach.

Your approach seems to be "the IO happens somewhere else". I'm not sure that that's much more of a compliment.

I/O is someone else's code, not yours. So it's someone else's responsibility to test it. You should just test the arguments that you pass (in case they are generated) and trust that the I/O is properly tested by its author.

In OOP everything happens somewhere else because anything can hold a reference to the variable and change its value right under your nose.

To me at least these are two different concepts.

Why is I/O always somebody else's code? (In my world, it's definitely not always somebody else's code, but I'm in embedded systems, which is an unusual environment.)

I mean, somebody has to be the "somebody else". What if it's you? Now you have to properly test the I/O code. (If you can get away with it never being you, that's fine, for you. Other people might not have that freedom, though.)

> In OOP everything happens somewhere else because anything can hold a reference to the variable and change its value right under your nose.

You're referring to a variable changing when it shouldn't. I don't think that's what the quote means. In OOP everything happens somewhere else because there's always another layer of indirection, and so the code that should change the variable isn't in the function (or file) that you're reading. This is why I say it's similar to your approach - the I/O is never in the function that you're reading. It still has to be somewhere, though. And the same thing that OOP does to the details of the computation (move it somewhere else), your approach does to the details of the I/O - it's not where you're looking, it's scattered somewhere else. For the same reason people complain about the effect of OOP in this regard, I distrust your approach for the I/O.

In OOP everything happens somewhere else because there's always another layer of indirection, and so the code that should change the variable isn't in the function (or file) that you're reading. - and because of that when you are debugging it may change right under your nose, with no code obviously responsible for that. There is a nuance, but the idea is the same - only the context is different. It's just that when you are debugging it hurts the most.

In the context of the article I/O is someone else's code. If it were my code it should be tested elsewhere, as if someone else wrote it. Not in every place that my application uses it.

In the particular case with writing the actual I/O code - I don't have enough experience, so I can't tell you. Maybe tests are absolutely necessary. Maybe mocks are the only way. I don't know.

What I do know is that programs transform input to output - input -> transformations -> output. You never care where the input comes from nor where the result of your transformations go. All you care is if your transformations are correct.

Your program may be very complex, with many inputs that you don't control, scattered around your logic. So prior to writing tests you have to decouple those dependencies from your logic and only then proceed with writing tests. If you are using functional language you may reduce every piece of logic to the simplest, smallest function possible, test it in the REPL, document it and move on without actually writing tests.

That's why you mock the IO - to make the IO call return an error.
You don't need to mock it. In your tests just manually call the code that is going to be executed if the I/O call returns an error. This goes for both OOP and FP.
I write fairly large backend systems that are continuously growing and facing evolving requirements. I follow those principles, and have yet to write a single test. It's just so much more robust to separate I/O code from other code, and verify that the building blocks of the system work.

I do this in Clojure, but am looking to migrate to Haskell soon to enforce this even further.

+1 for Clojure.

I suppose you will be migrating to Haskell because it is a PF language. What would you miss most from Clojure once you migrate?

I have used both languages quite a bit. What I miss from Clojure when doing Haskell is having access to a seemingly infinite collection of libraries (thanks to the JVM/maven/etc). There are of course many libraries on Hackage but the JVM ecosystem dwarfs most others.
Have you considered/tried out Frege?

https://github.com/Frege/frege

From the github page "The similarity to Haskell is actually strong enough that many users call it 'a Haskell for the JVM'."

> tests shouldn't be needed, at least in theory

Tests are very useful for distinguishing between "what a function does" and "what you think a function does". Especially when you're building on functions written by others. Types and documentation help too, but if someone's already misunderstood something, such descriptive information might still fit nicely with their incorrect world view.

Personally, I can't live without QuickCheck :)

Honest question: how do you validate business rules are adhered to without defect in FRP and how does FRP differ from OOP in that regard?
Stolen from Wikipedia: "FRP is a paradigm using the building blocks of functional programming." I doubt OP was referring to FRP.

In any case: in a functional programming language, you'd just unit test the building blocks (functions). Assuming every function is pure and total, these unit tests should be succinct and mirror your business logic quite closely.

Actually the OP uses Clojure in his project.
Not with unity tests, that's a certainty. Unity tests do not test business logic.

Yes, when people say that no tests are needed, it's hyperbolic. Tests are always needed, but you do not need to test all corner cases, just the ones created by business logic.