Recipe is a multi-language framework that makes it easy to set up fine-grained data fixtures dynamically prior to running integration tests. It generates ingredients based on your application's domain, which you combine into recipes that describe what data to prepare.
E.g., a Java integration test
Cake cake = oven.bake(Recipe.prepare(
new Customer("Jim"),
new Product("Cat Slippers", "#01234", 14.99)
.withAvailability(Region.CANADA, Region.TAIWAN),
new PurchaseOrder("Jim", "Cat Slippers", 2)
.withExpressShipping(true)
));
/* fetch what was prepared and run test logic */
String customerId = cake.get("Jim");
...
Recipe is compatible with monolithic and distributed architectures. You can combine ingredients that set up data across different services into a single recipe, which you can declare in any of the supported languages to perform the exact same setup.
Overall, recipe makes it easier to read, write, and maintain your tests. It is an alternative to pre-loading snapshots or making API calls to set up data (see static vs dynamic data setup).
Java | TypeScript | JavaScript | Python | |
---|---|---|---|---|
Ingredients | ✓ | ✓ | ✓ | TODO |
Hooks | ✓ | ✓ | ✓ | TODO |
I will add new languages in response to demand. Please create an issue if you need support for another language.
Recipe applies the metaphor of baking cakes to setting up data fixtures. The following examples use Java, but the supported languages have similar interfaces.
A recipe is an all-in-one declaration of a test's data preconditions. It describes what state should exist across all of your service datastores before your test runs. Baking a recipe instantiates a fresh copy of the described data at run-time. Here's what a typical test using recipe looks like:
// Step 1: declare a recipe
Recipe recipe = Recipe.prepare(
new IngredientA()
.withFoo("abc")
.withBar(7),
new IngredientB(true),
new IngredientC()
);
// Step 2: bake it
Cake cake = oven.bake(recipe);
// Step 3: run your test logic
...
Recipes are made up of ingredients—builder-style generated objects that describe the the entities or operations that you want to set up. Recipes may also include other sub-recipes.
Ingredients are generated from a cookbook—a yaml description of the entities—in your language of choice. While different services might be responsible for setting up the ingredients, they can all be baked together in a single recipe.
Recipes are composable and extendable. You can create a library of recipes that encapsulate common scenarios and share them between tests to avoid duplicated code and effort.
Recipe recipe = Recipe.prepare(
new MyCommonScenarioFixture(), // a custom recipe subclass
new IngredientA(),
new IngredientB(),
...
)
Recipes are baked in an oven. The oven serializes and dispatches each ingredient to a backend oven that knows how to instantiate it. The backend oven may live in the same project, or it may exist within the owning service. The backend oven deserializes the ingredient an invokes its corresponding implementation hook. Like ingredients, hooks are generated, but only partially: you must subclass them to actually carry out the persistence logic.
The result of baking is a cake—a sophisticated key-value dictionary that gets passed between ingredients during baking and gives the test writer context about what was persisted (e.g., the ids of created entities).
Recipe's key strength is that it decouples test fixture declaration from language and artchitecture. This means that your recipes for your backend integration tests in Java will look and behave the same as the recipes for your end-to-end protractor tests written in TypeScript, giving your team a consistent and simple language to declare data preconditions.
For a more complete specification, see the cookbook spec.
A cookbook.yaml file describes your ingredients. Think of an ingredient as a loose representation of one of your domain entities (or operations) whose purpose is to collect just enough information to set up the entity or perform an action.
In this example, we have a MOBA and an entity called Hero. All heroes have a name, level, and intelligence. They may also have a set of abilities in their repertoire along with a weapon. A hero without a level can be assumed to be at level 1. We can describe the Hero ingredient in a cookbook as follows:
cookbook.yaml
domain: "com.mycompany.moba"
ingredients:
- name: Hero
required:
- name: name
type: string
- name: intelligence
type: float
- name: level
type: int
default: 1
initializers:
- params: ["name", "intelligence"]
- params: ["name", "intelligence", "level"]
optionals:
- name: abilities
type: Ability...
- name: weapon
params:
- name: name
type: string
- name: damage
type: int
- name: ranged
type: boolean
enums:
- name: Ability
values:
- FIREBALL
- FORCE_PUSH
- STUN
If we run the above cookbook through the generator, you would get the following ingredient:
new Hero("Jim", 20.0f)
.withAbilities(Ability.FIREBALL, Ability.STUN)
.withWeapon("Bow", 40, true)
new Hero("Luke", 86.4f, 5) // constructor with level
.withAbilities(Ability.FORCE_PUSH)
Required properties are instantiated in the constructors (initializers), but can be left out if they have a default value (e.g., level
). Optional parameters correspond to withX
builder methods and can take a single typed value or be comprised of several properties (e.g., the optional weapon
has a name, damage, and ranged flag). Recipe supports a variety of property types; see the cookbook spec for more details.
Ingredients are simple builder objects that just record what you do with them. They are generated in the project and language of the test suite, which may be different from the project that owns the data, so they contain no instantiation logic. To actually perform the instantiation logic, you need to write implementation hooks.
As a rule of thumb, if you have different services responsible for different entities, you should have one cookbook per service.
Hooks take the information supplied by ingredients and perform the instantiation. Your hooks could be written in a different language than the ingredients, for example, if you have a Java backend that contains the hooks but you're generating the ingredients in TypeScript to run end-to-end tests from the browser. You generate base hooks using the generator and extend them to provide an implementation.
Let's look at an example of a hook using our Hero ingredient from above:
HeroHook.java
@Component
public HeroHook extends AbstractHeroHook { /* AbstractHeroHook is generated */
@Autowired
private HeroService heroService;
@Override
public void bake(HeroData data, Cake cake) {
// use the existing HeroService to instantiate the hero
String heroId = heroService.createHero(data.getName(), data.getIntelligence());
if (data.getLevel() > 1) {
heroService.levelUp(heroId, data.getLevel())
}
if (data.hasAbilities() {
for (Ability ability: data.getAbilities()) {
heroService.addAbility(heroId, ability.name());
}
}
if (data.hasWeapon()) {
HeroData.WeaponParams weapon = data.getWeapon();
heroService.addWeapon(weapon.name, weapon.damage, weapon.ranged);
}
// publish the id to the cake so we can access it from the test (more on this later..)
cake.publish(data.getName(), heroId));
}
}
HeroData
is a generated snapshot of the information supplied by the ingredient. Required parameters like name
have getters on the snapshot and are guaranteed to have a value. Optional parameters have a hasX/isX()
along with getters and setters since they may not have been specified in the ingredient.
The extended class must override the bake
method, which in this case simply invokes the existing hero service to perform validation and persist the hero to the database.
In this case, the hook was generated directly in the backend service and a Java domain service was autowired to perform the logic. It's just as valid to implement the hook sit of the backend or in the test project and have it invoke some API call. If you have logic that cannot be executed from an API, your hook may need to live within the backend service.
Ovens bake recipes by dispatching ingredients to backend ovens (possibly in diffferent services) that can invoke their implementation hooks. There are two types of ovens: an Oven
, which exists in the same project as your tests, and a BackendOven
which exists in the same project as your hooks.
An Oven is what you call to bake a recipe in your tests. Internally, the baking process produces json payloads that must be dispatched to a backend oven that can bake them. Recipe makes no assumptions about which delivery mechanisms you use, so you must configure dispatchers for each domain to perform the delivery.
Oven oven = new Oven(); // our oven instance
oven.addDispatcher("DomainA", payload -> {
/* TODO: your code that sends payload to Service A over REST */
...
return jsonResponse;
});
oven.addDispatcher("DomainB", payload -> {
/* TODO: your code that sends payload to Service B over SOAP */
...
return jsonResponse;
});
...
A dispatcher accepts a json payload, delivers it, and returns the response payload back to the oven.
A backend oven is at the receiving end of the payloads produced by an oven. The backend oven interprets the payload, invokes the appropriate hook(s), and returns a response payload that must then be returned to the calling test service. This response is the payload that must be returned to the dispatching oven.
public HttpResponse myRecipeEndpoint(String payload) {
String jsonResult = backendOven.bake(payload);
/* your code to send json payload back to test service */
...
}
A backend oven needs to be told about the hook for each ingredient it can bake.
BackendOven backendOven = new BackendOven(); // our backend oven instance
backendOven.registerHook(new HeroHook());
backendOven.registerHook(new ArenaSetupHook());
backendOven.registerHook(new InventoryItemHook());
...
If you're using java Spring, a simpler alternative to registering each hook is to make your hook subclasses components and autowire them in.
@Configuration
public class BackendOvenConfig {
@Autowired
private BaseIngredientHook[] hooks;
@Bean
public BackendOven getBackendOven() {
BackendOven backendOven = new BackendOven();
for (BaseIngredientHook hook: hooks) {
backendOven.registerHook(hook);
}
return backendOven;
}
}
For more information on the payloads and what information they contain, see recipe segmentation.
Groups of similar tests often have similar data setup. To avoid duplication, you can encapsulate common sets of ingredients into re-usable recipes or "fixtures".
One way to do this is to extend the Recipe class:
class SimpleBattleFixture extends Recipe {
public static final String JASON = "Json The Bearded";
public static final String PEARL = "Pearl";
public SimpleBattleFixture() {
super(
new Hero(JASON, 10.0f)
.withAbilities(Ability.FIREBALL)
.withWeapon("sword", 5, false),
new Hero(PEARL, 15.4f)
.withAbilities(Ability.FORCE_PUSH)
.withWeapon("bow", 3, true),
new ArenaSetup(Landscape.FOREST)
.withMatchup(JASON, PEARL)
);
}
}
You can then use it as an ingredient in another recipe because recipes are ingredients (did the metaphor just break?).
oven.bake(Recipe.prepare(
new SimpleBattleFixture(),
new InventoryItem(SimpleBattleFixture.JASON, "Healing Potion"),
new AttackPowerUp(SimpleBattleFixture.PEARL, 10, "7s")
));
In addition to subclassing Recipe, you can pass in stored instances of recipes or parameterized recipes generated from a function.
In general, you should create fixtures when you find yourself repeating the same sets of ingredients for a group of tests. It's useful to have a library of fixtures that describe high-level scenarios, adding ingredients using composition for additional fine-grained setup.
Note the use of string key constants to refer to hero entities created in the SimpleBattleFixture
. This is a useful way to refer to entities when other ingredients depend on them. This will be covered in more detail in the following section.
A cake is the result of baking a recipe. It is a sophisticated key-value dictionary that is useful for sharing information between ingredients (enabling dependencies), and returning information about what was persisted at bake-time to the test author for use in tests.
- Passing data to a test
- Passing data between ingredients
- Fixture keys
- Keyed ingredients and default keys
- Namespaces
- Cake key resolution
Tests often require information about what was baked. For example, if we bake a Hero
ingredient and a real hero gets persisted, then we may need that hero's database ID for use in an integration test.
@Override
public void bake(HeroData data, Cake cake) {
String heroId = heroService.createHero(data.getName(), data.getIntelligence());
cake.publish(data.getName(), heroId));
}
In the above example, we publish the created hero's ID to the cake using the hero's name as the key. After baking, we can retrieve hero IDs from the cake:
Cake cake = oven.bake(Recipe.prepare(
new Hero("Jim", 20.0f)
new Hero("Luke", 86.4f, 5)
));
String hero1Id = cake.get("Jim");
String hero2Id = cake.get("Luke");
/* test logic */
heroService.updateAttack(hero1Id, 8);
...
When you call oven.bake
, a single cake is created and passed around to each hook that gets invoked, and is finally returned to the test with all cake values that were added.
You can use the cake to facilitate setting up entities that depend on one another. For example, an InventoryItem belongs to a Hero and has a foreign key constraint, so it needs the hero's ID in order to be instantiated.
The way to deal with these relationships in Recipe is to pass the cake keys (strings) in place of depenent data that can only be resolved at bake time. The cookbook delcaration for an InventoryItem
might look like:
ingredients:
- name: InventoryItem
required:
- name: hero
type: string
- name: itemName
type: string
- name: quantity
type: int
default: 1
initializers:
- params: ["hero", "itemName"]
- params: ["hero", "itemName", "quantity"]
We would then bake the ingredient, passing the key for hero ID, "Jim" (which we know because we wrote the Hero hook to publish the ID under the hero's name).
oven.bake(Recipe.prepare(
new Hero("Jim", 20.0f),
new InventoryItem("Jim", "CatSlippers")
));
The InventoryItem's hook would then resolve the hero ID using cake.get
. We know that the ID is in the cake because ingredients are baked in order:
@Override
public void bake(InventoryItemData data, Cake cake) {
String heroId = cake.get(data.getHero());
inventoryService.addItem(heroId, data.getItemName());
}
When encapsulating ingredients in a fixture recipe, it's often helpful to expose any cake keys that are published when the fixture is baked.
class SimpleBattleFixture extends Recipe {
public static final String JASON = "Json The Bearded";
public static final String PEARL = "Pearl";
public SimpleBattleFixture() {
super(
new Hero(JASON, 10.0f)
.withAbilities(Ability.FIREBALL)
.withWeapon("sword", 5, false),
new Hero(PEARL, 15.4f)
.withAbilities(Ability.FORCE_PUSH)
.withWeapon("bow", 3, true),
new ArenaSetup(Landscape.FOREST)
.withMatchup(JASON, PEARL)
);
}
}
This allows convenient access to the cake values, which may be used in a test or passed in to other ingredients without copying the string literal key.
Cake cake = oven.bake(Recipe.prepare(
new SimpleBattleFixture(),
new Armor(SimpleBattleFixture.JASON, "Plate", 50)
));
String heroId = cake.get(SimpleBattleFixture.JASON);
/* test damage reduction on hero from presence of armor */
...
When we have many ingredients publishing data to the cake we're inevitably going to run into a case where two ingredients try to publish data under the same key. This will cause an error because all keys must be unique.
For example, the Hero hook described above publishes the hero ID under the hero's name. But what if we have two hero's named Jim?
oven.bake(Recipe.prepare(
new Hero("Jim", 20.0f),
new Hero("Jim", 86.4f, 5) // error!
));
One way to get around this issue is to manually specify the key we want to use when writing the recipe. We can do this by making Hero a keyed ingredient in the cookbook.
ingredients:
- name: Hero
keyed: true
...
Keyed ingredients have a special new property called key
. We could change the Hero hook to publish the ID under this new key property.
@Override
public void bake(HeroData data, Cake cake) {
String heroId = heroService.createHero(data.getName(), data.getIntelligence());
...
if (data.hasKey()) {
cake.publish(data.getKey(), heroId);
}
}
Now we can create two heroes named Jim that publish their ID under different keys using the keyed
operator that is only available to keyed ingredients.
Cake cake = oven.bake(Recipe.prepare(
new Hero("Jim", 20.0f).keyed("hero1"),
new Hero("Jim", 86.4f, 5).keyed("hero2")
));
String hero1Id = cake.get("hero1");
String hero2Id = cake.get("hero2");
Great, we now have a way to deal with duplicate cake keys! But what if having two heros with the same name is rare, and we would still like to publish the ID under the hero's name by default, only falling back to using keyed
when necessary to avoid conflicts? We can accomplish this by adding a defaultKey
to the hero ingreident in the cookbook and updating the hook to check if the key was overridden:
ingredients:
- name: Hero
keyed: true
defaultKey: name
required:
- name: name
type: string
...
@Override
public void bake(HeroData data, Cake cake) {
String heroId = heroService.createHero(data.getName(), data.getIntelligence());
...
cake.publish(data.getKey(), heroId); // key same as name unless overridden with keyed()
}
Cake cake = oven.bake(Recipe.prepare(
new Hero("Jim", 20.0f), // key = "Jim"
new Hero("Jim", 86.4f, 5).keyed("someHero") // key = "someHero"
));
The defaultKey
is set to a required string property (string so that it can be a cake key), and the key assumes the same value as the given required property.
In the examples we've seen so far, a hook has only published one value. But in reality, hooks can publish as many values as they want. If making an ingredient a keyed ingredient only gives us one unique key that we can override, how can we store more than just an ID without causing cake key conflicts? What if an ingredient sets up multiple database objects and we want to publish all of the IDs? We can do this by treating a keyed ingredient's key as a namespace that other values can be published under.
The cake supports hierarchical namespacing of keys. Namespaces are particularly useful for storing several cake values under the key of a keyed ingredient.
For example, let's say that each time a Hero is created, service logic dictates he or she receives a random quantitiy of gold. For convenience, we may want to store the gold quantity in the cake along with the hero's ID so that it can be easily accessed by tests. In the HeroHook we could write:
@Override
public void bake(HeroData data, Cake cake) {
// creates the hero with a random amount of gold
String heroId = heroService.createHero(data.getName(), data.getIntelligence());
cake.publish(data.getKey(), heroId);
cake.inNamespace(data.getKey(), () -> {
cake.publish("gold", heroService.getGold(heroId));
});
}
As before, we publish the ID under the hero's key, but now we also treat the key as a namespace and store the "gold"
key under that namespace. A test accessing these values would look like:
Cake cake = oven.bake(Recipe.prepare(
new Hero("Jim", 20.0f).keyed("myHero")
));
String heroId = cake.get("myHero");
int startingGold = cake.get(Cake.key("myHero", "gold"));
Notice the use of the helper method Cake.key
for declaring a hierarchical key. Now we can have many hero ingredients that each publish their ID and starting gold amount without conflicting cake keys.
When using subkeys like "gold"
in this fashion, it is good practice to provide a class-level constant for accessing it. The cookbook provides support for generating ingredients and hooks with subkey constants:
ingredients:
- name: Hero
constants:
GOLD: "gold"
required:
...
The constant is generated in the abstract hook superclass:
@Override
public void bake(HeroData data, Cake cake) {
// creates the hero with a random amount of gold
String heroId = heroService.createHero(data.getName(), data.getIntelligence());
cake.publish(data.getKey(), heroId);
cake.inNamespace(data.getKey(), () -> {
cake.publish(AbstractHeroHook.GOLD, heroService.getGold(heroId));
});
}
...and in the ingredient:
Cake cake = oven.bake(Recipe.prepare(
new Hero("Jim", 20.0f) // key defaults to "Jim"
));
String heroId = cake.get("Jim");
int startingGold = cake.get(Cake.key("myHero", Hero.GOLD));
When you call cake.get
either in a hook or in a test, the following rules are used to resolve the value.
- Search for the key within the current namespace
- Search for the key in each parent namespace up to the root
- If at the root namepace, search for the key within any namespace, as long as it is unambiguous
For example:
cake.publish("a", 1);
cake.inNamespace("b", () -> {
cake.publish("c", 2);
cake.publish("d", 3);
});
cake.inNamespace("e", () -> {
cake.publish("d", 5);
});
cake.get("a"); // => 1
cake.get(Cake.key("b", "c")); // => 2
cake.get("c"); // => 2
cake.inNamespace("b", () => {
cake.get("d"); // => 3
});
cake.get("d"); // error! ambiguous
Note: This is an advanced topic. Make sure you're comfortable with cakes, keyed ingredients, and namespaces before using contextful recipes.
Contextful recipes are another (optional) type of recipe you can use to make recipes a bit less verbose and easier to read. Let's say that we have a Hero named Jim, and Jim is a bit of a hoarder so he always has plenty of items in his inventory.
Recipe.prepare(
new Hero("Jim", 86.4f, 5),
new InventoryItem("Jim", "Healing Potion", 5),
new InventoryItem("Jim", "Invisibility Branch"),
new InventoryItem("Jim", "Lightning Boots"),
new InventoryItem("Jim", "Utlimate Cat Slippers")
);
It can be a little tedious to pass "Jim"
to each and every item (recall that InventoryItem has a foreign key constraint on a Hero ID). It would be cleaner if we could say "prepare all of the following inventory items assuming the owner is Jim" and not have to repeat the parameter. This is what contextful recipes do: they allow a keyed ingredient (in this case, the hero Jim) to assume the "context" of a recipe which all sub-ingredients can refer to using a special cake method.
Recipe.prepare(
new Hero("Jim", 86.4f, 5),
Recipe.context("Jim",
new InventoryItem("Healing Potion", 5),
new InventoryItem("Invisibility Branch"),
new InventoryItem("Lightning Boots"),
new InventoryItem("Utlimate Cat Slippers")
)
);
Recipe.context
prepares a recipe in the context of an existing cake key (the context key), which has a published value (the context). We can fetch the context, in this case the hero ID, in InventoryItemHook
by calling cake.getContext
:
@Override
public void bake(InventoryItemData data, Cake cake) {
/* resolve the hero id from the current context */
String heroId = cake.getContext();
heroService.addItem(heroId, data.getItemName());
}
This assumes that InventoryItem
is always prepared in the context of a Hero
ingredient, which is fine, but what if we want the ingredient to work within or without a context? We can declare two initializers for InventoryItem, one intended to be used in a context, and the other, without one:
ingredients:
- name: InventoryItem
required:
- name: hero
type: string
default: null
- name: itemName
type: string
initializers:
- params: ["hero", "itemName"]
- params: ["itemName"] # within hero context
Our InventoryHook could then use the cake helper method cake.getOrGetContext
, which gets the value for a key and falls back to fetching the value from the context if the key is null (note that the hero
property will be null if the non-contextful initializer is called).
@Override
public void bake(InventoryItemData data, Cake cake) {
/* resolve the hero id from either the hero property or the current context */
String heroId = cake.getOrGetContext(data.getHero());
inventoryService.addItem(heroId, data.getItemName());
}
And now we can use the ingredient within or without a context:
Recipe.prepare(
new Hero("Jim", 86.4f, 5),
Recipe.context("Jim",
new InventoryItem("Healing Potion", 5),
new InventoryItem("Invisibility Branch")
)
new InventoryItem("Jim", "Lightning Boots")
);
Note that you can still call the non-contextful initializer of InventoryItem inside of a contextful recipe, and in that case it will resolve the hero ID from the hero parameter, not from the context.
Recipe.context
can also accept a keyed ingredient directly, which has the same effect as first declaring the ingredient then passing in it's key:
Recipe.context(new Hero("Jim", 86.4f, 5), // context key = "Jim"
new InventoryItem("Healing Potion", 5),
new InventoryItem("Invisibility Branch"),
new InventoryItem("Lightning Boots"),
new InventoryItem("Utlimate Cat Slippers")
);
Coincidentally, contextful recipes also wrap child ingredients in the namespace of the context key. This means that any ingredients within a contextful recipe that publish cake values will automatically have those values published in the namespace of the context key. This provides an alternative to using keyed()
to prevent cake key conflicts:
Recipe.prepare(
Recipe.context("foo",
new Hero("Jim", 86.4f, 5), // keyed as "foo.Jim"
new InventoryItem("Healing Potion", 5),
),
Recipe.context("bar", // keyed as "bar.Jim"
new Hero("Jim", 86.4f, 5),
new InventoryItem("Invisibility Branch"),
)
);
Contextful recipes help you reduce the number of arguments you need to provide to ingredients, and happens to work nicely with parent-child relationships between entities. However, it is not intended as a way model complex relationships between entities in your domain, and should be seen first and foremost as a way to reduce verbosity or as a namespacing mechanism to prevent cake key conflicts.
The recipe generator is an executable jar that generates ingredients and hooks in all supported languages. For convenience, the generator can be invoked via the following build tools/plugins. If your particular build system is not supported, download and invoke the jar directly to perform the generation.
flavour
- generation type; one of: java-ingredient
, java-hook
, js-ingredient
, js-hook
, ts-ingredient
, ts-hook
cookbook
- path to the cookbook; defaults to cookbook.yaml
in the project directory
targetDir
- output directory for generated files
ingredientPostfix
- string postfix to add to each ingredient class name
javaPackage
- (java only) java package for generated classes
<plugin>
<groupId>ca.derekcormier.recipe</groupId>
<artifactId>recipe-generator-maven-plugin</artifactId>
<version>0.4.0</version>
<executions>
<execution>
<id>generate-ingredients</id>
<phase>generate-test-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<flavour>java-ingredient</flavour>
<cookbook>${project.basedir}/cookbook.yaml</cookbook>
<targetDir>${project.build.directory}/generated-test-sources/recipe</targetDir>
<javaPackage>ingredient.package.name</javaPackage>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>ca.derekcormier.recipe</groupId>
<artifactId>recipe-generator-maven-plugin</artifactId>
<version>0.4.0</version>
<executions>
<execution>
<id>generate-hooks</id>
<phase>generate-test-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<flavour>java-hook</flavour>
<cookbook>${project.basedir}/cookbook.yaml</cookbook>
<targetDir>${project.build.directory}/generated-sources/recipe</targetDir>
<javaPackage>hook.package.name</javaPackage>
</configuration>
</execution>
</executions>
</plugin>
"dependencies": {
"recipe-generator-node": "0.4.0"
}
"scripts": {
"generate": "recipe-generator ts-ingredient cookbook.yaml ./target/ingredients --ingredientPostfix Ingredient"
}
npm run generate
Download the latest jar-with-dependencies and run:
java -jar recipe-generator-x.y.z-jar-with-dependencies.jar
...to see the usage options.
The run-time libraries contain the core classes required by Recipe.
<dependency>
<groupId>ca.derekcormier.recipe</groupId>
<artifactId>recipe-java-runtime</artifactId>
<version>0.4.0</version>
</dependency>
"dependencies": {
"recipe-js-runtime": "0.4.0"
}
"dependencies": {
"recipe-ts-runtime": "0.4.0"
}
Every cookbook must declare a domain. This is a unique identifier that ovens use to dispatch ingredients to the correct backend oven for baking.
domain: "com.mycompany.myservice".ingredients"
Required parameters have a name, type, and an optional default value. Unlike optional params, required params always receive a value upon the creation of the ingredient.
ingredients:
- name: MyIngredient
- required:
- name: foo
type: string
default: "bar"
Every required param must either appear in every initializer or have a default value:
- required:
- name: foo
type: string
default: "bar"
- initializers:
- params: []
- params: ["foo"]
Generated:
new MyIngredient() // <- empty constructor, foo defaults to "bar"
new MyIngredient("baz")
Optional parameters result in withX
methods on an ingredient. Optionals may or may not receive a value depending on how the ingredient was used. There are two types of optionals: simple and compound.
Simple optionals take a single typed parameter in the withX
method.
ingredients:
- name: MyIngredient
- optionals:
- name: foo
type: boolean
Generated:
new MyIngredient()
.withFoo(true)
Compound optionals are made up of several typed parameters.
ingredients:
- name: MyIngredient
- optionals:
- name: foo
params:
- name: bar
type: int
- name: baz
type: string
Generated:
new MyIngredient()
.withFoo(10, "kittens")
Both simple and compound optionals can be repeatable
, meaning that if the withX
is called more than once, the values for each invocation will be stored and available to the hook.
ingredients:
- name: MyIngredient
- optionals:
- name: foo
repeatable: true
params:
- name: bar
type: string
- name: baz
type: int
Generated:
new MyIngredient()
.withFoo("a", 1)
.withFoo("b", 2)
.withFoo("c", 3)
The above is useful for storing map-like data.
Recipe supports a number of parameter types.
string
int
float
boolean
flag
The flag type is similar to a boolean, but rather than the optional method taking a boolean value, it gets set to true if the optional was invoked. For example, if we have a flag named enabled
:
new MyIngredient().withEnabled()
The value of the optional will be true in the hook, or false if withEnabled()
was never called. Contrast this to boolean optional which actually has three potentional values: was called and is true, was called and is false, or was never called (perhaps it should be called a troolean?).
The above primitive types can be turned into arrays by appending []
to the type.
ingredients:
- name: MyIngredient
- optionals:
- name: foo
type: string[]
Generated:
new MyIngredient()
.withFoo(new String[]{"foo", "bar"})
You can also turn primitives or arrays of primitives into varargs by appending ...
to the type. If the property appears in the last parameter list of the generated initializer or optional method, it will behave as a vararg (assuming language support), otherwise it will just be an array. As far as the hook is concerned, the resulting value will be an array.
ingredients:
- name: MyIngredient
- optionals:
- name: foo
type: boolean...
Generated:
new MyIngredient()
.withFoo(true, false, true, false)
You can declare enums in the cookbook to that can be used as parameter types.
ingredients:
- name: MyIngredient
optionals:
- name: foo
type: MyEnum
enums:
- name: MyEnum
values:
- A
- B
- C
Generated:
new MyIngredient()
.withFoo(MyEnum.B)
I'm considering adding support for generated objects to be used as parameters, but I'll likely wait until there is demand. Given that ingredients are intended to collect just enough information to set up a domain entity, I'm convinced this can be done in 99% of cases using the combination of primitives, compound optionals, repeatability, and enums.
Setting up data for integration tests is challenging.
Each integration test requires a carefully planned set of data to be prepared before running the test logic. In a distributed service architecture, this data may be persisted across several data stores. But how do you set up the data in the first place?
One approach is to maintain a set of database snapshots and load them up for each test or family of tests. Although this is usually faster than re-creating data at run-time, there are a few downsides:
- Lack of precision: snapshots require a lot of work to create. This encourages them to (sometimes innaproproately) be reused for several tests to save effort, despite containing more data than a particular test may need. The presence of extra data may introduce false positives.
- Lack of clarity: depending on the snapshot format (sql, xml, etc.), it can be difficult to determine at a glance exactly what data is being set up for a test, making the test harder to debug and modify.
- Migration overhead: databases and snapshots need to be migrated whenever the data schema changes. If the data format was text-based and laid out in a way to make it more readable, automatic migrations could muddle this organization.
- Difficult to extend: if two tests require similar but slightly different setups, it is tempting to duplicate the original snapshot and modify it slightly; not being able to break up and re-use common parts of snapshots leads to a lot of duplicated setup that is difficult to maintain.
Another approach is to set up data using a series of API calls, or, if testing within-service, calls to the controller, service, or data access layers of an application. This is slower but avoids migration issues since all setup is done at run-time and (ideally) undergoes validation, preventing impossible data states. However, there are still issues:
- Convoluted setup: a long series of api/service calls at the beginning of each test is hard to read, and similar to the snapshot approach it can be difficult to see the full picture of what is being set up.
- Language differences: two services written in different languages may want to set up the same data on a common third service, but that setup may look different depending on how the services implement their interface to the third service.
What's missing is a simple way to declare, combine, and maintain data fixtures such that each test sets up only the data it actually needs and in a language agnostic way. As architectures become less monolithic and more distrubuted with different languages and stacks, it is important to be to able to easily configure fine-grained data states for cross-service testing.
This is where Recipe comes in.
If you're integration testing within a service, then configuring the oven's payload delivery is easy. When configuring a dispatcher for your Oven, simply deliver the payload directly to the BackendOven instance.
Oven oven = new Oven();
BackendOven backendOven = new BackendOven();
oven.addDispatcher("DomainA", payload -> {
return backendOven.bake(payload);
});
Problem: A recipe setups up all of the data required for a test from a zero-state. Some of that data is singleton—there's a single, shared copy of the data that is assumed to exist, and you don't want to duplicate that data by running two tests with recipes back-to-back.
If you clear your data stores between tests, then this isn't really a problem. But you might choose not to clear your data for performance reasons and if you know that a new set of data generated by a recipe will be indepdent from the data in a previous test.
One solution is to put singleton logic in the hook of the ingredient responsible for setting up the singleton data:
@Test
public void testFoo() {
oven.bake(Recipe.prepare(
new SingletonConfiguration(), // singleton data gets set up
...
));
...
}
@Test
public void testBar() {
oven.bake(Recipe.prepare(
new SingletonConfiguration(), // not set up again
...
));
...
}
@Component
public SingletonConfigurationHook extends AbstractSingletonConfigurationHook {
@Autowired
private ConfigurationService configService;
@Override
public void bake(SingletonConfigurationData data, Cake cake) {
/* only persist configuration if it doesn't already exist */
if (!configService.hasX()) {
configService.setupX(data.getX());
}
}
}
In the case where you have a bunch of data that is assumed to always exist in a database, it's often useful to create a singleton "bootstrap" ingredient and hide it within some base fixture that all other fixtures extend.
Recipe delivers each ingredient to its owning service for baking. However, rather than delivering ingredients one-by-one (and incuring potential network delays), ingredients are delivered in batches called payloads.
When you bake a recipe (1), it first gets segmented into groups of contiguous same-service ingredients (2). Each of these groups (payloads) are serialized into json and dispatched to the owning service along with the current cake (3). After arriving at the service, the corresponding hooks are invoked for each ingredient and the updated cake is sent back. This process repeats for each group of ingredients until the final cake is returned to the test.
Thanks to Elle Chau for the logo.