Tip – Architectural Principles
An excellent way of enforcing those behavioral constraints is automated testing. You can write a test suite and run it against all subclasses of a specific supertype to enforce the preservation of behaviors.
Let’s jump into some code to visualize that in practice.
Project – Liskov Substitution
To demonstrate the LSP, we will explore some scenarios. Each scenario is a test class that follows the same structure:
namespace LiskovSubstitution;
public class TestClassName
{
public static TheoryData InstancesThatThrowsSuperExceptions = new TheoryData()
{
new SuperClass(),
new SubClassOk(),
new SubClassBreak(),
};
[Theory]
[MemberData(nameof(InstancesThatThrowsSuperExceptions))]
public void Test_method_name(SuperClass sut)
{
// Scenario
}
// Other classes, like SuperClass, SubClassOk,
// and SubClassBreak
}
In the preceding code structure, the highlighted code changes for every test. The setup is simple; I use the test method to simulate code that a program could execute, and just by running the same code three times on different classes, each theory fails once:
• The initial test passes
• The test of a subtype respecting the LSP passes
• The test of a subtype violating the LSP fails.
The parameter sut is the subject under test, a well-known acronym.
Of course, we can’t explore all scenarios, so I picked three; let’s check the first one.
Scenario 1: ExceptionTest
This scenario explores what can happen when a subtype throws a new exception type.The following code is the consumer of the subject under test:
try
{
sut.Do();
}
catch (SuperException ex)
{
// Some code
}
The preceding code is very standard. We wrapped the execution of some code (the Do method) in a try-catch block to handle a specific exception.The initial subject under test (the SuperClass) simulates that at some point during the execution of the Do method, it throws an exception of type SuperException. When we execute the code, the try-catch block catches the SuperException, and everything goes as planned. Here’s the code:
public class SuperClass
{
public virtual void Do()
=> throw new SuperException();
}
public class SuperException : Exception { }
Next, the SubClassOk class simulates that the execution changed, and it throws a SubException that inherits the SuperException class. When we execute the code, the try-catch block catches the SubException, because it’s a subtype of SuperException, and everything goes as planned. Here’s the code:
public class SubClassOk : SuperClass
{
public override void Do()
=> throw new SubException();
}
public class SubException : SuperException { }
Finally, the SubClassBreak class simulates that it is throwing AnotherException, a new type of exception. When we execute the code, the program stops unexpectedly because we did not design the try-catch block for that. Here’s the code:
public class SubClassBreak : SuperClass
{
public override void Do()
=> throw new AnotherException();
}
public class AnotherException : Exception { }
So as trivial as it may sound, throwing that exception breaks the program and go against the LSP.