Modular test cases via model factories

Intro

In my previous post, we discussed how we can do modular data-driven testing, and using that initial test case to build out entire test suite. With this design, we’ll have a lot of test cases with some data model, that must be created in the test case (i.e. NOT passed in via Test Case Variables)…but how should we do it?

Not so good implementation

If you were like me back in 2021, when I was first switching the test cases over to model-driven testing design, you probably hard coded the model instantiation into the test case itself…

Let’s take a brief look at a test case I implemented before whipping out the builders…and on a different test suite (sanity test case for practice contracts)…


 ContractModel model = new ContractModel(Formats.DateFormat.parse('08-16-2021'),
    Formats.DateFormat.parse('08-16-2021'), 
    3, 
    'dummyContractLabelName', 
    Frequency.QUARTERLY, 
    45, 
    5,
     true, 
     26, 
     Signatory.PHYSICIAN, 
     new BankDetailModel(3), 
     )


WebUI.navigateToUrl(initURL)

WebUI.click(findTestObject('Page_Red Group Practice (Practice) - Zoho CRM/div_New'))

WebUI.click(findTestObject('Page_Create Contract - Zoho CRM/input_Organization Name_Crm_CustomModule3_COBJ3CF112'))

WebUI.click(findTestObject('Page_Create Contract - Zoho CRM/a_SMD-BETA'))

WebUI.setText(findTestObject('Page_Create Contract - Zoho CRM/input_Contract Signed Date_Crm_CustomModule3_COBJ3CF84'), 
    Formats.DateFormat.format(model.getSignDate()))

//... rest of test case....

This is good in the sense that we’re not having to hard-code the values into the field setting (and having to change all those dozens of times…!) However, the problems are several :

  • this is hard-to-read, especially with the several arguments all of the same type
  • this is a nightmare to maintain.
    • if the ContractModel definition changes, we have to go back and change every single test case . In this test suite, it just so happens that we ended up with 27 test cases to change!
    • if the base data that we’re passing into most of the test cases (e.g. the sign date), changes, we’ll have to go back and change every test case
  • what about parent test cases, that may, for example, want a sanity model to, for example, set start dates? For example, a practice test case that has a rate card test case, whose start and end dates should match those on the contract…

We can do better than this!

Introducing the BaseModelFactory!

For the better part of three months, I was stuck with constructing the models directly inside the test cases, and thought little to nothing of it…

That was until I started noticing some common functionality that was needed:

  • every test suite, is based on some model whose type is common to that test suite, and
  • every test suite, has some sanity test case

This prompted me to write some BaseModelFactory:

public abstract class BaseModelFactory<T> {

	public abstract T createSanityCase();

}

ContractFactory

With the BaseModelFactory implemented, we can now move our model-construction concerns to ContractFactory:

public class ContractFactory extends BaseModelFactory<ContractModel> {

	@Override
	public ContractModel createSanityCase() {
		Date startDate = SMDDateUtils.WithinLastMonth();

		return new ContractModel(startDate,
				startDate,
				3,
				'sanity case model',
				Frequency.QUARTERLY,
				45,
				5,
				true,
				26,
				SMDConstants.TERMS_AND_CONDITIONS_URL,
				Signatory.PHYSICIAN,
				new BankDetailModel(3),
				);
	}
}

Using it in sanity test case

With that, the top of our test case became:

// type-casting, so that IntelliSense can tell what we're working with here
practiceProfile = (PracticeProfile)practiceProfile

ContractModel model = new ContractFactory().createSanityCase()

//...rest of test case...

That first line is there because of another design decision, that I don’t want to talk about on here…

I’d pick that second line over any model constructor call any day!

What about all the other test cases in your contract test suite?

They became much simpler to maintain. I can now move their model-construction logic out of the test case itself and into this new ContractFactory, under some self-documenting method names:

public class ContractFactory extends BaseModelFactory<ContractModel> {

	@Override
	public ContractModel createSanityCase() {
		Date startDate = SMDDateUtils.WithinLastMonth();

		return new ContractModel(startDate,
				startDate,
				3,
				'sanity case model',
				Frequency.QUARTERLY,
				45,
				5,
				true,
				26,
				SMDConstants.TERMS_AND_CONDITIONS_URL,
				Signatory.PHYSICIAN,
				new BankDetailModel(3),
				);
	}

	public ContractModel createDummyModel() {
		Date startDate = SMDDateUtils.WithinLastMonth(),
		signDate = SMDDateUtils.WithinLastMonthOf(startDate);

		return new ContractModel(signDate,
				startDate,
				3,
				"dummy model",
				15,
				15,
				26,
				SMDConstants.TERMS_AND_CONDITIONS_URL,
				Signatory.PHYSICIAN,
				new BankDetailModel(),
				);
	}

	public ContractModel createWithStartDate(Date date) {
		ContractModel model = this.createDummyModel();

		model.setSignDate(date);
		model.setStartDate(date);
		model.setContractLabelName("has start date ${SMDDateUtils.ToDateString(date)}");

		return model;
	}

	public ContractModel createWithNegativeACHFee() {
		ContractModel model = this.createDummyModel();
		model.setBankDetails(new BankDetailModel(0, -2.0));
		return model;
	}

	public ContractModel createWithThreeDigitACHFee() {
		ContractModel model = this.createDummyModel();
		model.setBankDetails(new BankDetailModel(0, 125.0));
		return model;
	}

	public ContractModel createWithNegativeCardFee() {
		ContractModel model = this.createDummyModel();
		model.setBankDetails(new BankDetailModel(-2.0));
		return model;
	}

	public ContractModel createWithThreeDigitCardFee() {
		ContractModel model = this.createDummyModel();
		model.setBankDetails(new BankDetailModel(125.0));
		return model;
	}

	public ContractModel createWithNegativeCheckFee() {
		ContractModel model = this.createDummyModel();
		model.setBankDetails(new BankDetailModel(0, 0, -2.0));
		return model;
	}

	public ContractModel createWithThreeDigitCheckFee() {
		ContractModel model = this.createDummyModel();
		model.setBankDetails(new BankDetailModel(0, 0, 125.0));
		return model;
	}

	public ContractModel createWithNegativeAgeLimit() {
		ContractModel model = this.createDummyModel();
		model.setPediatricAge(-2);
		return model;
	}

	public ContractModel createWithZeroAgeLimit() {
		ContractModel model = this.createDummyModel();
		model.setPediatricAge(0);
		return model;
	}

	public ContractModel createWithLaterSignDate() {
		ContractModel model = this.createDummyModel();
		model.setSignDate(SMDDateUtils.WithinNextMonthOf(model.getStartDate()));
		return model;
	}

	public ContractModel createWithNegativeProcessingGap() {
		ContractModel model = this.createDummyModel();
		model.setProcessingGapDays(-2);
		return model;
	}

	public ContractModel createWithNegativeFirstPayout() {
		ContractModel model = this.createDummyModel();
		model.setFirstPayoutGapDays(-2);
		return model;
	}

	public ContractModel createWithNegativeContractTerm() {
		ContractModel model = this.createDummyModel();
		model.setContractTerm(-2);
		return model;
	}

	public ContractModel createWithZeroContractTerm() {
		ContractModel model = this.createDummyModel();
		model.setContractTerm(0);
		return model;
	}

	public ContractModel createWithNoRenewal() {
		ContractModel model = this.createDummyModel();
		model.setContractLabelName("This has no renewal");
		return model;
	}

	public ContractModel createWithSignDateZero() {
		ContractModel model = this.createDummyModel();
		model.setSignDate(null);
		model.setContractLabelName("The sign date is all zeroes");
		return model;
	}

	public ContractModel createWithStartDateZero() {
		ContractModel model = this.createDummyModel();
		model.setStartDate(null);
		model.setContractLabelName("The start date is all zeroes");
		return model;
	}
}

Much, much better!

Even better, when we have one test suite that is pretty similar to another in scenario concerns (e.g. rate card and discount test suites), we can copy and paste the factory for the first test suite, implement the second one to the specific test case/data concerns, and just like that we’re halfway done with the test suite!

Improving upon BaseModelFactory

It was later on, late into the refactoring of the rate card and discount test cases, that I started noticing:

some models have child models, we need to change them for different scenarios, and doing that can get…cumbersome…

No worries, let’s introduce a simple helper method for that on the BaseModelFactory:

	public T changeChildModel(T model, Object childModel, Closure onChangeChildModel) {
		onChangeChildModel(childModel);

		return model;
	}

Now, if we wanted to use it to, for example, create RateCardModel without startDate, we can do that like this:

	private RateCardModel changeChildModel(RateCardModel model, Closure onChangeChildModel) {
		return super.changeChildModel(model, model.getMemberFeeStructures()[0], onChangeChildModel);
	}

	public RateCardModel createWithoutStartDate(Date endDate) {
		RateCardModel model = this.createDummyModel(null, endDate);

		return this.changeChildModel(model, { BaseMemberFeeStructureModel childModel -> childModel.setEnrollmentStartDate(null); });
	}

Conclusion

Model factories can take your test cases to a whole new level, by separating the data concerns from your actual test case, and if there is problem with the data, you don’t have to dig through your test case code just to fix it.

It also helps “future-proof” your test code base, because any changes to the models (e.g. moving from hard-coded sign and start dates to having them start around today’s date), happens outside the test cases.

Questions? Concerns? Comments? Let’s hear them in the comments section below.

2 Likes

Thank you @mwarren04011990 for sharing those tips with us. We would like to share it with a larger audience on our social media groups.

Please do; // as long as I get credit for it

1 Like