Small talk about test-driven baking
Something like a preface
The article “How Two Programmers Bread Bread” at first seemed to me just a joke - attempts to build some kind of “design” are so absurd, based on those “requirements” that the “manager” makes. But in every joke there is some truth ... In general, a question arose to myself: how will the approach that I try to adhere to in my practice work in this situation? What grew up while trying to give an answer, in fact, is presented below.
TDD + Smalltalk
Actually, the essence of the approach used by me is put in the heading, but, I think, some clarifications are required here.
I am a proponent of “clean” TDD (TDD is a test-driven development , hence the feminine). “Purity” in this case means that at certain stages of software development, namely immediately after receiving functional requirements and before the next release (that is, at the stages of requirements analysis, design and construction of program code), the developer acts in full accordance with this methodology, without deviating from her.
TDD “cleanliness” is ensured by combining the “classic style” of creating tests (based on state analysis) and “TDD with surrogate objects (mocks)” (based on the analysis of the interaction of the developed subsystem with other objects). Moki allows you to design a top-down system (to perform functional decomposition, creating a "blank frame" of the system), and the classic TDD then provides the development of a bottom-up implementation. And, in my experience, this approach is very well (at least better than all the mainstream alternatives I know) combined with the use of the Smalltalk environment. Here I will not go into details, leaving them for the next articles, but simply offer to see how this approach works on this somewhat strange, but from this, and something interesting example.
Step 1. “Guys, we need to make bread”
We create the first test, simultaneously inventing the necessary terminology (if you wish, you can probably call it a metaphor).
So far, the only thing we know: at the exit, the system gives out "bread." Since no functionality in the existing “statement” is connected with bread, there is a desire to simply check the output object for belonging to the corresponding class (
And who makes the bread? The baker, probably ... Accordingly, in the test we fix the following functional requirement, which we managed to extract from the existing “statement” of the problem: the baker can be asked to make bread, in response to which he should give us an object of the corresponding class. This requirement relates to the functionality of the baker, the class for the first test is called (for now) simply
BakerTests, and in it we create a test:
BakerTests >> testProducesBread | baker product | baker := Baker new. product := baker produceBread. product class should be: Bread
The implementation of the test is as trivial as it is.
- We create classes
Bread. By the way, the Smalltalk system itself will tell us the need to do this when compiling the test, asking how it should understand the names unknown to it
Bread. The system will also express bewilderment about the identifier
produceBread, but for now we will simply assure it that we are in control of the situation, and it has the right to exist (being the name of the message).
- Immediately after compilation, you can run the test. In the process of its execution, the system will encounter a problem: a method with a name is
produceBreadnot defined in the class
Baker(we just promised to take care of this at compilation time), which Smalltalk will not fail to tell us by showing the debugger window. And right in the debugger, we ask the system to create this method in the desired class (
Baker) and immediately set its implementation:
Baker >> produceBread ^ Bread new
- After that we continue (we continue) the test execution and make sure that everything went without errors. The first test is implemented.
Summing up the results of this iteration, we can note for ourselves that absolutely no requirements are imposed on bread, the purpose of the corresponding class is not clear, and it probably makes sense to try a manager about this: that’s why we are forced to limit ourselves to such a trivial test, and it seems that We started the development not from the highest level of abstraction, but this often leads to problems in the future. Nevertheless, within the framework of our example, we assume that the first iteration is completed on this.
Step 2. “We need the bread not only made, but baked in the oven”
We fix the received crumbs of new knowledge about our system in the test. What did we find out? Only that the baker interacts with the oven during baking. To fix this, surrogate objects will be useful to us (after all, they are intended for such things). I use the Mocketry framework . With it and I got the following code:
BakerTests >> testUsesOvenToProduceBread | product | [ :oven | baker oven: oven. [ product := baker produceBread ] should strictly satisfy: [ (oven produceBread) willReturn: #bread ]. product should be: #bread ] runScenario
Here we have done the following:
- We created a test script object by sending a message to an
#runScenarioexternal block (the term “closure” may be closer to someone).
- They said that
oventhis is a surrogate object (Mocketry automatically initializes the parameters of the script block accordingly).
- They said that when the baker is asked to make bread (the first nested block), the oven should receive a message
#produceBread(in the second nested block passed as the message argument
#satisfy:). In fact, this is the first condition of the test.
- In addition, we asked our fake oven in response to this message to return a certain object (
#bread), which in the future should be the result of the initial request for baking bread. The identity of these objects is the second condition of the test. And here we are not interested in the nature of this resulting object, but only its identity to the object that the furnace gave out is important. Therefore, in this role, we use, in fact, a simple string constant:
I also note that the slightly refactored version of the test is presented here: we already got rid of the duplication associated with the creation of a baker in both tests, “pulled” it into the instance variable and initialized in the method
#setUp, which is automatically called before each test starts:
BakerTests >> setUp super setUp. baker := Baker new.
I note that in the process of writing the test I had to make the following decision: the baker knows in advance which stove he uses - it becomes part of his condition. This solution is actually not very important, because if necessary it will be easy to change: if the stove becomes known only at the time of work, we add the parameter to
produceBread; and if it must be obtained from somewhere else, we will introduce an object that will give us the right stove at the right time.
To implement this test, we slightly redo the method
#produceBreadin the baker:
Baker >> produceBread ^ oven produceBread
In the process of compiling this method, the system is interested in what is oven. In response, we explain that we want to create an instance variable. After that, by running the test, we see the debugger message and understand that the dissatisfaction of the system is due to the lack of the necessary setter. We create it directly from the debugger, without interrupting the execution of the test:
Baker >> oven: anOven oven := Oven new
Immediately, during compilation, we create a class
Continuing to run the test, we see that it successfully fulfills. But running all the tests in our system (and there are already two), we see that the first one has broken. If the reason is not obvious in advance, we can easily find it out from the diagnostic message or by analyzing the state of the system according to the current stack in the debugger: the stove is not set. Well, we will provide a default stove (here I am laying in that, as in Squeak and Pharo , the
Objectmethod call is already provided
#initializefor creating the instance - in other Smalltalk environments this is very - yes, actually, it is very - simple to implement this ourselves):
Baker >> initialize super initialize. oven := Oven new.
We start the test - the system reports that the method
#produceBreadin the class is
Ovennot implemented. We realize right there:
Oven >> produceBread ^ Bread new
We continue execution - the test turns green from correctness. All (both) tests are now green. We turn to refactoring ... But there seems to be nothing to refactor (which is understandable, because we almost do not write code - thanks to PM for our happy programming).
The result obtained after this iteration, as well as after the previous one, looks somewhat doubtful: the baker himself, it turns out, does practically nothing. But what happened is the simplest solution in the given conditions. In short, all questions are for PM :)
Step 3. “We need stoves of different types”
Again: why is it necessary - history is silent. But since the conditions of the game have been accepted, we play: for each type of stove you can create and implement a test. However, it immediately turns out that actually with something like this “staging” you don’t have to! We will verify this with the example of a gas stove (the only one that we will need in the future):
GasOvenTests >> testProducesBread | oven | oven := GasOven new. oven produceBread should be a kind of: Bread
But, having logically inherited the gas stove from
Oven, where it
#produceBreadhas already been implemented, we get the test right away in green. In general, this is a bad symptom: we seem to have written a meaningless test. The accusations against the manager become a commonplace, I miss them ... :) Perhaps in the real task there is some kind of functionality connected with different types of furnaces, but in this case it is covered in such darkness that it makes no sense to fantasize.
Step 4. “We need the gas stove to not be able to bake without gas”
Again there are more questions than answers; The simplest, but seemingly appropriate solution to this wording looks like this for me:
GasOvenTests >> testConsumesGasToProduceBread [ :gasProducer | oven gasProducer: gasProducer. [ oven produceBread ] should strictly satisfy: [ gasProducer consume ] ] runScenario GasOven >> produceBread gasProducer consume. ^ super produceBread >> gasProducer: gasProducer gasProducer := aGasProducer
Does the stove hardly change the gas source at its discretion? And how exactly consumption occurs is not yet clear - therefore, we simply inform the source about the fact of consumption.
The solution is essentially identical to the previous one, which is easy to explain - the tasks are set in the same style, and accordingly we solve them in similar ways that once worked (after all, there were no problems).
Like last time, one test broke (gas source is not set by default), repair:
GasOven >> initialize super initialize. gasProducer := GasProducer new. GasProducer >> consume
- Yes, we leave this method (I hope so far) empty, since no specific requirements for it have been set.
Step 5. “We need so that the ovens can also bake pies (separately - with meat, separately - with cabbage), and cakes”
Again the fog: what does it mean to bake pies? how do they differ from bread? and from the cake? I saw two possible options:
- These products differ in some of their properties (more precisely, in behavior) - but we don’t know anything about this, so this option does not give us anything in this situation. We drop it.
- Products differ in manufacturing method. This option is more productive in terms of knowledge about the system: to create a product, you need to specify the method of its manufacture. We fix this in the test.
What is the manufacturing method called? In my opinion, this is a recipe ...
testUsesOvenToProduceByRecipe | product | [ :oven | baker oven: oven. [ product := baker cookWith: #recipe ] should strictly satisfy: [ (oven produce: #recipe) willReturn: #product ]. product should be: #product ] runScenario
Here we recorded the following:
- A request to the baker to cook something accompanied by a recipe
- The baker passes the same recipe to the stove (in reality, most likely, this is done in some other way, but we don’t know anything about it now - so, we make it easier)
- What the baker receives from the stove gives out as the final result
- The connection between the recipe and the final product, unfortunately, remains “behind the scenes” - simply because nothing is clear about this (yet?).
You can do a few more iterations, “throwing” tests on various types of recipes ... but for this I would like to know something about how this should work. You can, of course, dream up, but it is a pity to time ... Therefore we pass to the following point.
Step 6. “We need to bake bread, pies and cakes according to different recipes”
We, it seems, have already done this ... well, as we could.
Step 7. “We need bricks to be burned in the furnace”
If we consider that a brick can be baked by a baker according to a recipe (and why not? We didn’t receive any information contradicting this), then we don’t need to do anything again ... well, except to add one more test to the collection of tests we haven't done yet (so far) recipes.
In general, everything seems to be ...
What did we get? Six classes ... and not very much (even frankly speaking - just a little) functionality ... But personally, I am inclined to "thank" our manager for this.
Baker Bread Oven ElectricOven GasOven GasProducer
It will be interesting to hear your opinion about the result and the process ...