TDD Study Notes [4] --- How to isolate dependencies and tdd Study Notes
Preface I believe that many readers have heard of "testability" and even those who want to survive it. They also feel that it is simply inexplicable and futile. In this article, we will focus on the dependency between objects and the direct dependency between objects. To avoid design and test problems caused by dependency, this article will clearly explain how to isolate the dependency of objects. Finally, we will explain how to use a simple stub object for testing, instead of relying on the objects actually dependent on when executing in the production code. In addition, I think the test can bring about huge advantages. How can we verify the design of objects and let the test tell you. What is dependency? Suppose there is a Validation service, which should be verified based on the id and password entered by the user. The commercial logic of the CheckAuthentication method of Validation is as follows:
- Obtain the password that exists in the data source based on the id (only store the result after the hash operation ).
- Hash is performed based on the input password.
- Check whether the password returned by the data source matches the hash calculation result of the input password.
The simple program code is as follows (the content of AccountDao and Hash is not the focus, so it is omitted to save space ):
1 using System; 2 3 public class Validation 4 {5 public bool CheckAuthentication (string id, string password) 6 {7 // get the database, password corresponding to id 8 AccountDao dao = new AccountDao (); 9 var passwordByDao = dao. getPassword (id); 11 // hash calculation 12 Hash = new hash (); 13 var hashResult = Hash for the input password. getHashResult (password); 15 // compare the hash password with the Database password. 16 return passwordByDao = hashResult; 17} 18} 19 20 public class AccountDao21 {22 internal string GetPassword (string id) 23 {24 // connect DB25 throw new NotImplementedException (); 26} 27} 28 29 public class Hash30 {31 internal string GetHashResult (string passwordByDao) 32 {33 // use SHA51234 throw new NotImplementedException (); 35} 36}
First
Separation of dutiesTherefore, the data is obtained through the AccountDao object, while the Hash operation uses the Hash object. Everything is reasonable. So what is the problem? Let's look at the dependency issue again. The commercial logic of the CheckAuthentication method is only three steps to obtain the password, obtain the hash result, and check whether the comparison is the same. However, in the object-oriented design
Single responsibilityTherefore, different roles are assigned to different objects, and then the interaction between objects is used to meet user requirements. However, for the CheckAuthentication method of Validation, it doesn't matter whether it cares about AccountDao or Hash object, because it is not in its business logic. However, in order to obtain the password, the AccountDao object is initialized directly. In order to obtain the hash result, the Hash object is initialized directly. Therefore, the Validation object is directly dependent on the AccountDao object and the Hash object. Shows the category relationship: what is the problem of direct dependency?
From the unit test perspective, when you want to test whether the CheckAuthentication method of Validation meets expectations, it is impossible to test the Validation object separately. Because the Validation object is directly dependent on other objects. As mentioned in the previous article, we establish a unit test for CheckAuthentication. The program code is as follows:
[TestMethod ()] public void CheckAuthenticationTest () {Validation target = new Validation (); // TODO: the initialization value is string id = string. empty; // TODO: the initialization value is string password = string. empty; // TODO: Initialize to the appropriate value bool expected = false; // TODO: Initialize to the appropriate value bool actual; actual = target. checkAuthentication (id, password); Assert. areEqual (expected, actual); Assert. inconclusive ("verify the correctness of this test method. ");}
Regardless of arrange, when calling the CheckAuthentication method of the Validation object, the GetPassword method of AccountDao will be used to connect to the database and obtain the corresponding password data. Do you still remember our definition and principles of unit testing? Unit tests must be independent of external environments, categories, resources, and services, but cannot be directly dependent on each other. In this way, the logic of the test target object is as expected. In addition, unit testing needs to run quite quickly. If unit testing requires database resources, it means to execute unit testing and you also need to set database online or external service settings, and the execution will take some time. This is actually an integrated test, not a unit test. In addition to the test procedure, the design of elastic design depends on other objects directly. What is the problem? I hope that you can keep this sentence in mind when reading this series of articles: the test program is used outside the simulation and may be used by users, it may also be the usage of external objects. Therefore, when we use a test program, we will encounter problems caused by direct dependency, which also means that this production code directly depends on the AccountDao and Hash objects when the Validation object is used. When the requirement changes, for example, if the data source is changed from a database to a read csv file, a new AccountFileDao object will be written and the content of the Validation object will be modified. Or directly read the AccountDao database content and rewrite it to read the csv file content. These two changes both violate the Open Close Principle (OCP) Principle, which means that the coupling of the object is too high. When the demand changes, it cannot be easily expanded and converted. If the context content in the object is changed directly, the object is not stable enough. In the software development process, demand changes are normal and frequent. Just as we used to store files through a floppy disk, and then CD, flash drive, DVD, Blu-Ray DVD, or even cloud hard disk, If we directly write the backup service method content to an access floppy disk, as the Times change and technology changes, we have to constantly modify the original program content, and we cannot ensure that the results meet expectations. Even the original test program needs to be modified, because the content and requirements have changed, and the changes in the business logic of the original object have been relatively affected. Therefore, whether for elasticity or testability, we should avoid directly dependency between objects. (Imagine, in practice systems, object dependency is not just a two-layer relationship. A depends on B, and B depends on C and D, which indicates that A depends on B, C, and D. Dependency relationships will be explosive and complex. The reason for how to isolate the dependency between objects is that the action to initialize the dependency object is written in the content of the target object, the conversion of this dependent object cannot be determined by the external. Therefore, the focus of isolation dependency is very simple. Do not initialize dependency objects directly in the target object. How? First, for scalability, an interface is defined so that the target object only depends on the interface, which is also an interface-oriented programming method. The commercial logic of the CheckAuthentication method is abstracted as follows:
1 public interface IAccountDao 2 {3 string GetPassword (string id); 4} 5 6 public interface IHash 7 {8 string GetHashResult (string password); 9} 10 11 public class AccountDao: IAccountDao12 {13 public string GetPassword (string id) 14 {15 throw new NotImplementedException (); 16} 17} 18 19 public class Hash: IHash20 {21 public string GetHashResult (string password) 22 {23 throw new NotImplementedException (); 24} 25} 26 27 public class Validation28 {29 private IAccountDao _ accountDao; 30 private IHash _ hash; 31 32 public Validation (IAccountDao, IHash hash) 33 {34 this. _ accountDao = dao; 35 this. _ hash = hash; 36} 37 38 public bool CheckAuthentication (string id, string password) 39 {40 // get the password 41 var passwordByDao = this for the id in the database. _ accountDao. getPassword (id); 42 // For the input password, perform the hash operation 43 var hashResult = this. _ hash. getHashResult (password); 44 // compare the hash password to whether it matches the password in the database. 45 return passwordByDao = hashResult; 46} 47}
As you can see above, the objects directly dependent on each other are now dependent on interfaces. The CheckAuthentication logic is clearer, as described in the annotation: get the password corresponding to the id in the data (how to get the data, do not pay attention to) hash the password (how to hash, do not pay attention) compare the hash result with the password stored in the data. The type dependency of the return comparison result is as follows: This is the interface-oriented design. The action of the original initialization dependent object can be passed in by the Public constructor of the target object through the instance to which the interface belongs, that is, after the initialization of the target object is completed.
Control inversion (IoC), which provides abstraction for mutually dependent components and transfers the acquisition of dependent (low-level module) objects to a third party (system) for control.,That is, the dependent object is not directly obtained through new in the dependent module class.Dependency injection (DI ),It provides a mechanism to pass the reference of the dependent (low-level module) object to the dependent (high-level module) object.
The initialization action is transferred from the original target object to the target object, which is called "control inversion", that is, IoC. The dependent object is exposed through the target object constructor, which is called "dependency injection", that is, DI. IoC and DI are the same thing: Let the external determine the dependent object of the target object.
In the original article, you can refer to Martin Fowler's article: Inversion of Control Containers and the Dependency Injection pattern.
As a result I think we need a more specific name for this pattern. Inversion of Control is too generic a term, and thus people find it confusing. As a result with a lot of discussion with various IoC advocates we settled on the name Dependency Injection.
In this way, the target object can focus on its own business logic, not directly dependent on any entity object, but only on interfaces. This is also an extension point or joint of the target object, which provides future implementation of new objects to expand or convert dependent object modules without having to modify the context content of the target object. IoC is used to isolate the dependency between objects and bring about the expansion points mentioned above. This is actually the most basic testability. In the next section, we will introduce why such a design can provide testability. How to perform a test for the target object just designed in IoC mode. When a unit test is established through VS2013, the test program code is as follows:
[TestMethod ()] public void CheckAuthenticationTest () {IAccountDao accountDao = null; // TODO: Hash hash Hash = null for initialization; // TODO: the value is initialized to the appropriate value Validation target = new Validation (accountDao, hash); string id = string. empty; // TODO: the initialization value is string password = string. empty; // TODO: Initialize to the appropriate value bool expected = false; // TODO: Initialize to the appropriate value bool actual; actual = target. checkAuthentication (id, password); Assert. areEqu Al (expected, actual); Assert. Inconclusive ("verify the correctness of this test. ");}
Have you seen it? Visual Studio automatically lists the parameters required by the constructor. Why does this design method help us to test the CheckAuthentication method of Validation independently? Next, stub of "manual design" will be used. Let's take a look at it. In the CheckAuthentication method, the IAccountDao GetPassword method is used to obtain the password corresponding to the id. The IHash GetHashResult method is also used to obtain the hash calculation result. Then, check whether the two are the same. The interface can be expanded, and the features of polymorphism and overload (if it is inherited from the parent class or abstract class, rather than the actual interface), we will take IAccountDao as an example to create a StubAccountDao type, to implement IAccountDao. In addition, in the GetPassword method, "Hello World" is fixed regardless of the input parameter, which indicates the password that Dao returns. The program code is as follows:
public class StubAccountDao : IAccountDao{ public string GetPassword(string id) { return "Hello World"; }}
In the same way, let the GetHashResult of StubHash return "Hello World", representing the hash result. The program code is as follows:
public class StubHash : IHash{ public string GetHashResult(string password) { return "Hello World"; }}
Smart readers should know that the next step is to write 3A pattern for unit testing. The unit testing program code is as follows:
[TestMethod ()] public void CheckAuthenticationTest () {// arrange // initialize StubAccountDao as the IAccountDao execution object IAccountDao = new StubAccountDao (); // initialize StubHash, IHash hash = new StubHash (); Validation target = new Validation (dao, hash); string id = "random write "; string password = "write"; bool expected = true; bool actual; // act actual = target. checkAuthentication (id, password); // assert Assert. areEqual (expected, actual );}
In this way, we can make our test target object: Validation, which is not directly dependent on AccountDao and Hash object. It is simulated by stub object to verify the CheckAuthentication method logic of the Validation object, whether it meets expectations. The test program uses the Stub object, and its category is shown in the figure below: the extended thinking gives readers a job. If today's CheckAuthentication method is dependent on an object of a random number generator, the verification logic is to check whether the entered password is equal to the data storage password + the random number generator 」. How can I write such program code? After writing, how to test? If IoC and Stub object are not used, can they still be tested? How can I simulate or guess the random number for this test execution? This is an example of a standard RSA token used for login. It is also the most common example of IoC and Stub. Readers can write this simple function by themselves and try to test it to realize the benefits of this design and the so-called testability. Conclusion if we take the purpose of "testability" as a result of so much effort for testing, it is easy to get twice the result. Developers often think: "Why do I spend so much effort on testing? Even if I don't write a test, the execution results of the program are still correct and there is no error !」 However, in fact, the focus of such design is the design's flexibility and scalability. For example, when the data source changes or the Hash algorithm module changes, the program code in Validation is not required because the business logic remains unchanged. It does not need to be changed to the original AccountDao, because its responsibilities and content have not changed. The change is: "Validation uses the new data source value and the new Hash algorithm to obtain the hash calculation result 」. Therefore, you only need to change the injection dependency object. In unit testing, this method is used to test the target object independently. Therefore, it is also called the testability of objects. This is also why we can use testability to determine whether the design of the object has the characteristics of low coupling, and low coupling is one of the good design pointers. However, developers must always know a logic: "If a program is not testable, it means that its object design is not good enough. However, the program is testable, which does not necessarily mean that the object design is good .」 In addition, I would like to ask the reader to calm down and think about it. If today's design is to generate test cases based on requirements, the test program will generate target objects. We only focus on the target object, how to meet the test cases, that is, the use requirements. All duties other than the target object are handed over to external entities for implementation. In this IoC example, we only need to abstract the responsibilities of non-target objects to interact through interfaces, and do not need to think about how to implement interfaces. So, which of the following is short and easy to write the program code of the Validation object? Based on my own experience, when I am familiar with this TDD method, when I have a test case and write a test program, the time for completing the behavior of the target object will be quite short. Because the goal and scope of the design are limited to the responsibility of performing this test case only when this target object is completed, other complex implementations are handled by the objects behind the interface. This is the interface-oriented design, that is, the abstract design of the object, the abstract design can make the object more stable, stable, and not affected by external changes. Because of TDD, developers will find that the design of the target object will not have much dependency, and it will not be too small, but it will be just right. Because there are too many dependencies, the test program will be difficult to write, which also indicates that the target object is complex,
Too detailed responsibilities and too fragmented responsibilities. As a result, a combination of more than a dozen objects may be required to complete a function.. Can a dozen objects abstract and aggregate some responsibilities and change them to three dependent objects to meet this test case? This is a test program to verify whether the responsibilities are too fragmented. The dependency is too small, which is not a big problem. However, because it is directly dependent on other objects and leads to excessive responsibilities of the target object, to test a behavior, a considerable number of test cases need to be prepared to meet all execution paths. In this case, you can test the program to verify whether the object design conforms to the single responsibility principle. The testability is to verify whether the design of the object is low coupling and whether it has a good design for expansion and convertible changes through the test program. It would be a pity to regard the testing program, test case, and testability as the result of a more secure program. Because that little benefit is just the tip of the iceberg of the whole treasure. When you realize this whole treasure, you will naturally feel that the CP Value of the test program is very high and scary!
Note: This series is a process for me to record the blog digest of danale after I graduated from the development industry one year later, non-original, thanks to 91 and other predecessors