Definition
Separate the logic of each individual rule and its effects into its own class. Separate the selection and processing of rules into a separate Evaluator class.
- Separate individual rules from rules processing logic.
- Allow new rules to be added without the need for changes in the rest of the system.
Abstract UML Class Diagram
classDiagram
IRule <|.. BaseRule
BaseRule <|-- Rule
RulesEvaluator --> IRule
RulesEvaluator --> IRuleResult
RulesChain --> IRule
class IRule {
<<interface>>
+IRuleResult Evaluate()
}
class IRuleResult {
<<interface>>
+bool IsSuccess
+string Message
}
class BaseRule {
+IRuleResult Evaluate()
}
class Rule {
+IRuleResult Evaluate()
}
class RulesChain {
+IRule MainRule
+RulesChain Next
}
class RulesEvaluator {
+Evaluate(IRule rule)
}
Participants
The classes and objects participating in this pattern are:
-
IRule
Defines the interface for all specific rules.
-
IRuleResult
Defines the interface for the results of all specific rules.
-
BaseRule
The base class provides basic functionality to all rules that inherit from it.
-
Rule
The class represents a concrete implementation of the BaseRule class.
-
RulesChain
It is a helper class that contains the main rule for the current conditional statement and the rest of the conditional chain of rules.
-
RulesEvaluator
This is the main class that supports the creation of readable rules and their relation. It evaluates the rules and returns their results.
Rules Design Pattern C# Code
Test’s Test Case
Consider that we have to automate a shopping cart process. During the purchase, we can create orders via wire transfer, credit card or free ones through promotions. Our tests’ workflow is based on a purchase input object that holds all data related to the current purchase e.g. type of purchase and the total price.
public class PurchaseTestInput
{
public bool IsWiretransfer { get; set; }
public bool IsPromotionalPurchase { get; set; }
public string CreditCardNumber { get; set; }
public decimal TotalPrice { get; set; }
}
A sample conditional test workflow for our test logic without any design pattern applied could look like the following code.
PurchaseTestInput purchaseTestInput = new PurchaseTestInput()
{
IsWiretransfer = false,
IsPromotionalPurchase = false,
TotalPrice = 100,
CreditCardNumber = "378734493671000"
};
if (string.IsNullOrEmpty(purchaseTestInput.CreditCardNumber) &&
!purchaseTestInput.IsWiretransfer &&
purchaseTestInput.IsPromotionalPurchase &&
purchaseTestInput.TotalPrice == 0)
{
this.PerformUIAssert("Assert volume discount promotion amount. + additional UI actions");
}
if (!string.IsNullOrEmpty(purchaseTestInput.CreditCardNumber) &&
!purchaseTestInput.IsWiretransfer &&
!purchaseTestInput.IsPromotionalPurchase &&
purchaseTestInput.TotalPrice > 20)
{
this.PerformUIAssert("Assert that total amount label is over 20$ + additional UI actions");
}
else if (!string.IsNullOrEmpty(purchaseTestInput.CreditCardNumber) &&
!purchaseTestInput.IsWiretransfer &&
!purchaseTestInput.IsPromotionalPurchase &&
purchaseTestInput.TotalPrice > 30)
{
Console.WriteLine("Assert that total amount label is over 30$ + additional UI actions");
}
else if (!string.IsNullOrEmpty(purchaseTestInput.CreditCardNumber) &&
!purchaseTestInput.IsWiretransfer &&
!purchaseTestInput.IsPromotionalPurchase &&
purchaseTestInput.TotalPrice > 40)
{
Console.WriteLine("Assert that total amount label is over 40$ + additional UI actions");
}
else if (!string.IsNullOrEmpty(purchaseTestInput.CreditCardNumber) &&
!purchaseTestInput.IsWiretransfer &&
!purchaseTestInput.IsPromotionalPurchase &&
purchaseTestInput.TotalPrice > 50)
{
this.PerformUIAssert("Assert that total amount label is over 50$ + additional UI actions");
}
else
{
Debug.WriteLine("Perform other UI actions");
}
Improved Version Rules Design Pattern Applied
PurchaseTestInput purchaseTestInput = new PurchaseTestInput()
{
IsWiretransfer = false,
IsPromotionalPurchase = false,
TotalPrice = 100,
CreditCardNumber = "378734493671000"
};
RulesEvaluator rulesEvaluator = new RulesEvaluator();
rulesEvaluator.Eval(new PromotionalPurchaseRule(purchaseTestInput, this.PerformUIAssert));
rulesEvaluator.Eval(new CreditCardChargeRule(purchaseTestInput, 20, this.PerformUIAssert));
rulesEvaluator.OtherwiseEval(new PromotionalPurchaseRule(purchaseTestInput, this.PerformUIAssert));
rulesEvaluator.OtherwiseEval(new CreditCardChargeRule<CreditCardChargeRuleRuleResult>(purchaseTestInput, 30));
rulesEvaluator.OtherwiseEval(new CreditCardChargeRule<CreditCardChargeRuleAssertResult>(purchaseTestInput, 40));
rulesEvaluator.OtherwiseEval(new CreditCardChargeRule(purchaseTestInput, 50, this.PerformUIAssert));
rulesEvaluator.OtherwiseDo(() => Debug.WriteLine("Perform other UI actions"));
rulesEvaluator.EvaluateRulesChains();
Rules Design Pattern Explained C# Code
All concrete rules classes should inherit from the base rule class.
public abstract class BaseRule : IRule
{
private readonly Action actionToBeExecuted;
protected readonly RuleResult ruleResult;
public BaseRule(Action actionToBeExecuted)
{
this.actionToBeExecuted = actionToBeExecuted;
if (actionToBeExecuted != null)
{
this.ruleResult = new RuleResult(this.actionToBeExecuted);
}
else
{
this.ruleResult = new RuleResult();
}
}
public BaseRule()
{
ruleResult = new RuleResult();
}
public abstract IRuleResult Eval();
}
public class CreditCardChargeRule : BaseRule
{
private readonly PurchaseTestInput purchaseTestInput;
private readonly decimal totalPriceLowerBoundary;
public CreditCardChargeRule(PurchaseTestInput purchaseTestInput, decimal totalPriceLowerBoundary, Action actionToBeExecuted)
: base(actionToBeExecuted)
{
this.purchaseTestInput = purchaseTestInput;
this.totalPriceLowerBoundary = totalPriceLowerBoundary;
}
public override IRuleResult Eval()
{
if (!string.IsNullOrEmpty(this.purchaseTestInput.CreditCardNumber) &&
!this.purchaseTestInput.IsWiretransfer &&
!this.purchaseTestInput.IsPromotionalPurchase &&
this.purchaseTestInput.TotalPrice > this.totalPriceLowerBoundary)
{
this.ruleResult.IsSuccess = true;
return this.ruleResult;
}
return new RuleResult();
}
}
public class RulesEvaluator
{
private readonly List<RulesChain> rules;
public RulesEvaluator()
{
this.rules = new List<RulesChain>();
}
public RulesChain Eval(IRule rule)
{
var rulesChain = new RulesChain(rule);
this.rules.Add(rulesChain);
return rulesChain;
}
public void OtherwiseEval(IRule alternativeRule)
{
if (this.rules.Count == 0)
{
throw new ArgumentException("You cannot add ElseIf clause without If!");
}
this.rules.Last().ElseRules.Add(new RulesChain(alternativeRule));
}
public void OtherwiseDo(Action otherwiseAction)
{
if (this.rules.Count == 0)
{
throw new ArgumentException("You cannot add Else clause without If!");
}
this.rules.Last().ElseRules.Add(new RulesChain(new NullRule(otherwiseAction), true));
}
public void EvaluateRulesChains()
{
this.Evaluate(this.rules, false);
}
private void Evaluate(List<RulesChain> rulesToBeEvaluated, bool isAlternativeChain = false)
{
foreach (var currentRuleChain in rulesToBeEvaluated)
{
var currentRulesChainResult = currentRuleChain.Rule.Eval();
if (currentRulesChainResult.IsSuccess)
{
currentRulesChainResult.Execute();
if (isAlternativeChain)
{
break;
}
}
else
{
this.Evaluate(currentRuleChain.ElseRules, true);
}
}
}
}
It provides methods for defining the IF, IF-ELSE and ELSE clauses. The IF is declared via Eval method, IF-ELSE through OtherwiseEval and ELSE with OtherwiseDo. Also, it holds the EvaluateRulesChains method that evaluates the entirely configured chain of conditions and executes all associated actions. It works internally with another class called RulesChain.
public class RulesChain
{
public IRule Rule { get; set; }
public List<RulesChain> ElseRules { get; set; }
public bool IsLastInChain { get; set; }
public RulesChain(IRule mainRule, bool isLastInChain = false)
{
this.IsLastInChain = isLastInChain;
this.ElseRules = new List<RulesChain>();
this.Rule = mainRule;
}
}
For more detailed overview and usage of many more design patterns and best practices in automated testing, check my book “Design Patterns for High-Quality Automated Tests, C# Edition, High-Quality Tests Attributes, and Best Practices”. You can read part of three of the chapters:
Defining High-Quality Test Attributes for Automated Tests
Benchmarking for Assessing Automated Test Components Performance
Generic Repository Design Pattern- Test Data Preparation
Rules Design Pattern Configuration
rulesEvaluator.Eval(new PromotionalPurchaseRule(purchaseTestInput, this.PerformUIAssert));
private void PerformUIAssert(string text = "Perform other UI actions")
{
Debug.WriteLine(text);
}
The rule-associated action is defined as a private method in the class where the RulesEvaluater is configured. All actions associated with rules can be separated in different private methods.
rulesEvaluator.Eval(new CreditCardChargeRule(purchaseTestInput, 20, () => Debug.WriteLine("Perform other UI actions")));
rulesEvaluator.Eval(new CreditCardChargeRule(purchaseTestInput, 20, () =>
{
Debug.WriteLine("Perform other UI actions");
Debug.WriteLine("Perform another UI action");
}));
In my opinion, this approach leads to unreadable code, so I stick to the first one.
public class CreditCardChargeRule<TRuleResult> : BaseRule
where TRuleResult : class, IRuleResult, new()
{
private readonly PurchaseTestInput purchaseTestInput;
private readonly decimal totalPriceLowerBoundary;
public CreditCardChargeRule(PurchaseTestInput purchaseTestInput, decimal totalPriceLowerBoundary)
{
this.purchaseTestInput = purchaseTestInput;
this.totalPriceLowerBoundary = totalPriceLowerBoundary;
}
public override IRuleResult Eval()
{
if (!string.IsNullOrEmpty(this.purchaseTestInput.CreditCardNumber) &&
!this.purchaseTestInput.IsWiretransfer &&
!this.purchaseTestInput.IsPromotionalPurchase &&
this.purchaseTestInput.TotalPrice > this.totalPriceLowerBoundary)
{
this.ruleResult.IsSuccess = true;
return this.ruleResult;
}
return new TRuleResult();
}
}
This is how a sample concrete rule result class looks like.
public class CreditCardChargeRuleAssertResult : IRuleResult
{
public bool IsSuccess { get; set; }
public void Execute()
{
Console.WriteLine("Perform DB asserts.");
}
}
The usage is straightforward.
rulesEvaluator.OtherwiseEval(new CreditCardChargeRule<CreditCardChargeRuleRuleResult>(purchaseTestInput, 30));
rulesEvaluator.OtherwiseEval(new CreditCardChargeRule<CreditCardChargeRuleAssertResult>(purchaseTestInput, 40));
The same rule is used twice with different actions wrapped in different result classes.
Summary
Consider using the Rules Design Pattern when you have a growing amount of conditional complexity.
Separate the logic of each rule and its effects into its class.
Divide the selection and processing of rules into a separate Evaluator class.
