@artisnull/gin
v1.1.6
Published
Library that handles state management, business logic, and api calls in an easy-to-use manner.
Downloads
18
Maintainers
Readme
GIN
Have some api calls and/or state management to do? Gin is here to make your life easier.
Please consume responsibly ;)
Table of Contents
Getting Started
API
<Store cargo={cargo: object} deeds={deeds: array}>
useStore(selector) => ({cargo: {}, deeds: {}})
useNamedStore(name, selector) => ({cargo: {}, deeds: {}})
withStore(selector)(Component)
withNamedStore(name, selector)(Component)
deed.action
deed.request
deed.flow
combineDeeds(deed), combineDeeds(deed[] | deed | deed{})
Test Utils
FAQ's
Getting Started
Basic Usage
Using Gin consists of these elements:
- Store Component
- Cargo
- Deeds
- Subscribers using
useStore
orwithStore
For example:
// Parent Component
import Store, { deed } from './pathTo/store';
const initialCargo = {
count: 0,
};
const deeds = [action.deed.called('increment').thatDoes((extras, count) => ({ count: count++ }))];
export const Parent = () => (
<Store cargo={initialCargo} deeds={deeds}>
<Child />
</Store>
);
in the child component:
// Child Component
import { useStore } from './pathTo/store';
export const Child = () => {
const { deeds, cargo } = useStore();
return (
<div>
<span>Current count = {cargo.count}</span>
<button onClick={() => deeds.increment(cargo.count)}>Increment</button>
</div>
);
};
Clicking on the button in the child component would update cargo.count
and your child would display the incremented value automagically.
Learning
Here's what you should take a second to learn about before we start:
Cargo
Cargo is the data that a Store holds and exposes for subscribers. Cargo is updated by deeds
. When a deed
has updated cargo, the newly calculated cargo will be emitted to subscribers. This is known as a shipment.
Cargo vs State
Cargo is similar what other libraries call state
. But since every implementation of that state
is different, we're going to avoid that name. Especially since cargo
can live alongside React's this.state
, it's just confusing to call both of them state.
In React, Class components have local state, which is an instance variable that you access in your class with this.state
,
and to set the state, this.setState(newState)
. This function does more than just set the variable, it also tells react to maybe update the UI. Functional components however, don't have this.state
, they get their data through props
. Props are values that are passed to a direct descendant, to a React Component, accessible by that component for use, but not directly updatable by the component.
Cargo is related to both of those: it's an object that holds data you want to be able to access. Unlike this.state
or props
:
- You have to subscribe (using
useStore
orwithStore
) to have access - It's scoped from the
Store
to any descendant component, no matter how deep or how many otherStore
s are also descendant. - You can only change it through
deeds
Cargo can be used in components that also use this.state
, and cargo can be passed into a component as props
by using withStore
.
Notes
Deeds are the majority of the magic, but cargo is just as important. The Store keeps track of a cargo that you define, and that you then update through deeds. This cargo is pushed to subscribers when it updates.
Cargo should be treated as Immutable, that is, don't modify it, copy it.
The value that you return from
.thenDoes
or.thatDoes
will me merged into the existing cargo
For example:
// GOOD
.thatDoes(function(extras, count) {
return {
count: count++
}
})
---
// BAD
.thatDoes(function(extras, count) {
// The current cargo is passed as an extra
const cargo = extras.cargo
cargo.count = count++
})
If you are nesting objects in your cargo, be sure to merge the nested objects to avoid overwriting the existing reference without bringing in the nested values:
// GOOD
.thatDoes(function(extras, count) {
const cargo = extras.cargo
return {
count: {
...cargo.count, // Merge the nested object
current: cargo.count.current++
}
}
})
---
// BAD
.thatDoes(function(extras, count) {
const cargo = extras.cargo
return {
count: { // Any elements other than 'current' in cargo.count will be undefined in the new cargo
current: cargo.count.current++
}
}
})
Deeds
What is a Deed? Conceptually it is a function that you define and pass into the Store, where it is magically bound with extra arguments and functionality, then made available to subscribers bundled with all the magic. This will make more sense with examples!
Deed Types
Currently, there are three types of deeds: deed.action
, deed.request
, and deed.flow
.
Action Deed - deed.action
const actionDeed = deed.action.called('increment').thatDoes(function(extras, count) {
return {
count: count++,
};
});
The core method of an action deed is the thatDoes
function. Here we pass in a function that will later be called with some arguments (or none), as well as the extra
argument that is added by the store.
The value returned by thatDoes
is merged into your cargo object.
Request Deed - deed.request
const requestDeed = deed.request
.called('getData')
.hits('/route/with/data')
.withVerb('get')
.afterwards(async function(extras, res) {
const data = await res.json();
});
Request Deeds make it simple to define an api call and do something with the response. Take a look at the api for the full list of methods available.
Flow Deed - deed.flow
const flowDeed = deed.request
.called('flow')
.thatStartsWith(actionDeed)
.whichAdvancesOn('shipment')
.andThenCalls(requestDeed)
.withOriginalArgs();
Flow deeds allow you to call multiple deeds in a single flow, with control over when the next set of deeds should be called, and where they should get their arguments from.
Subscription
In order to get access to the deeds
or cargo
you've just learned about, you need to subscribe to the store. Thankfully, doing so is very easy!
Any component that uses useStore
or withStore
is subscribed to the nearest ancestor Store
.
We prepend the file extension for subscribed components with .sub.
to easily tell which components are subscribed and which aren't. Example: form.sub.tsx
. Likewise, we prepend the file extension for stores with .store.
. Example: form.store.tsx
. If a component is both subscribed to a store, and a store itself: form.store.sub.tsx
.
As a general rule, useStore
is used for functional components, and withStore
for class components.
Also not that a component can be a child of a Store
without being subscribed to it. It won't event know that any Store
exists, as long as there is none of the above subscription methods.
Advanced Subscriptions
In the case where you want to subscribe to a store that isn't the nearest ancestor Store
, you can use useNamedStore
or withNamedStore
. All behavior is the same other than where you've subscribed.
To do this, pass a unique name to your Store
, like <Store name="user-store">
, then in your component: useNamedStore('user-store')
, and now you've subscribed to user-store
, regardless of how far above the component the Store
is, and how many Stores
are in between here and user-store
.
You should not, and most likely cannot, subscribe to a Child or Sibling
Store
. This introduces a host of unsupported behavior and stale data/rehydration management that is not currently included.
Understanding the flow
Let's use the following example (same as above):
// Parent Component
import Store from '@artisnull/react-gin';
import { deed } from '@artisnull/gin';
const initialCargo = {
count: 0,
};
const deeds = [action.deed.called('increment').thatDoes((extras, count) => ({ count: count++ }))];
export const Parent = () => (
<Store cargo={initialCargo} deeds={deeds}>
<Child />
</Store>
);
in the child component:
// Child Component
import { useStore } from '@artisnull/react-gin';
export const Child = () => {
const { deeds, cargo } = useStore();
return (
<div>
<span>Current count = {cargo.count}</span>
<button onClick={() => deeds.increment(cargo.count)}>Increment</button>
</div>
);
};
Here's what's going on:
initialCargo
and thedeeds
array are registered with Store, wrapping them and making them accessible to subscribers- Child component subscribes to the Store with
useStore
, getting access to the cargo and deeds registered in Step 1
** The button is clicked
onClick calls
() => deeds.increment(cargo.count)
, the registered deedThis maps to the function passed to
.thatDoes
:count => ({ count: count++ });
The
.thatDoes
ofdeed.increment
is executed, the result{count: 1}
is passed into the Store's update mechanismAfter the batch timer elapses, the update is merged into
initialCargo
The new cargo:
{count: 1}
is pushed to subscribers (the Child component)The new cargo is different that the current cargo, so it triggers a rerender in Child
Child shows the new cargo: "Current count = 1"
Diagrams
This may help you visualize the relationship between <Store>
and useStore
or withStore
.
useStore
: Use with functional components, recommended for most use cases.Cannot be used with class components
cargo Deeds
|___ ___|
| |
Store
|
---Component---
| useStore |
| ___|___ | Exposed within the component
| | | |
|cargo deeds|
---------------
withStore
: Use with functional components, or class componentsWhen possible, use
useStore
, it is more performant and doesn't pollute the virtual-dom
cargo Deeds
|___ ___|
| |
Store
|
withStore
___|___ Passed as props to the component
| |
cargo deeds
|___________|
|
---Component---
| |
---------------
useNamedStore
: Use with functional components, useful if you need to get cargo from another Store that's not the closest ancestor Store.Cannot be used with class components
name cargo Deeds
| |___ ___|
| | |
|_________Store
|
cargo Deeds |
|___ ___| |
| | |
Store ________|
x __| Closest store is bypassed,
| Named store is used instead
---Component---
|useNamedStore|
| ___|___ | Exposed within the component
| | | |
|cargo deeds|
---------------
withNamedStore
: Use with functional components, or class componentsWhen possible, use
useNamedStore
, it is more performant and doesn't pollute the virtual-dom
name cargo Deeds
| |___ ___|
| | |
|_________Store
|
cargo Deeds |
|___ ___| |
| | |
Store ________|
x ___| Closest store is bypassed,
| Named store is used instead
withNamedStore
___|___ Passed as props to the component
| |
cargo deeds
|___________|
|
---Component---
| |
---------------
Folder Layout
It is recommended to use the following patterns when structuring your project:
Basic
feature-name/
index.ts -> export {default} from './feature-name.store'
feature-name.store.tsx
sub-feature-name.sub.tsx
store-logic.ts
Example
form/
index.ts -> export {default} from './form.store'
form.store.tsx
form-page.sub.tsx
submit-button.sub.tsx
store-logic.ts
styles.scss
Many deeds and/or large cargo
feature-name/
index.ts -> export {default} from './feature-name.store'
feature-name.store.tsx
sub-feature-name.sub.tsx
styles.scss
store-logic/
index.ts -> export {default as cargo} from './cargo'
export {default as deeds} from './deeds'
deeds.ts
cargo.ts
Example
form/
index.ts -> export {default} from './feature-name.store'
form.store.tsx
form-page.sub.tsx
submit-button.sub.tsx
styles.scss
store-logic/
index.ts -> export {default as cargo} from './cargo'
export {default as deeds} from './deeds'
deeds.ts
cargo.ts
File extensions:
Using specific notation allows us to easily see from a glance which kind of file and logic appears in a specifc file:
Store
Files should include.store
in the filenamefile.store.jsx
- Subscription Files should include
.sub
in the filenamefile.sub.jsx
- If both
Store
and subscriber, include both.sub
and.store
in the filenamefile.store.sub.jsx
- Any other react component has no specific treatment, just use
.jsx
like normal
Store Logic
In simple cases, both cargo
and deeds
should live in a file named store-logic
, which should define and export both items.
In cases where there are many deeds, and/or your cargo is large or very nested, use a folder named store-logic
with a cargo
and deeds
file that export their respective items, along with an index
that combines the two and re-exports them.
Index files
index
files may seem like extra boilerplate, but they encourage consistency and stability within a project.
For instance, take this line, that appears in every Store
file:
import { cargo, deeds } from './store-logic';
Store
doesn't know if store-logic
is a file or folder, nor should it have to. It may start as file when there is relatively low complexity, then at a later date change to a folder that includes seperate cargo
and deeds
files. Using an index
file in the store-logic
folder means that we don't have to update the reference in our Store
file.
Assuming deeds
and cargo
files export default
their contents, our index
file should look like this:
export { default as cargo } from './cargo';
export { default as deeds } from './deeds';
And now the Store
file is none the wiser, everything just works.
The same concept should apply to the feature folder index
's as well:
export { default } from './form.store';
Doing this means that from the outside, I just import Form from '/pathTo/Form'
, and I now have the freedom to make changes to files within the folder without worrying about breaking the reference (in most cases).
Testing
When you test your deeds or your .sub
components, you'll want to use gin
's test utils to make your life easier.
Take a look in
examples/form-example
for real tests showcasing different flows
Some other notes about testing:
- Currently enzyme's
shallow
doesn't play nicely with all hooks. If you usemockStores
withstub.stores
, you will be able to useshallow
in most cases. But if you run into issues, you may need to switch tomount
. - Your testing environment may or may not have a
window
shim, and that may or may not haveglobal.fetch
. Becausegin
relies on this, you may need to mock outglobal.fetch
in your testing environment. deeds
are async, so make sure you properlyawait
a deed invocation, otherwise you may see race conditions or bugs- When integration testing or testing a flow that involves a new
cargo
shipment, do the following:
beforeAll(() => {
global.fetch = () => null; // shim window for our test environment
});
beforeEach(() => {
jest.useFakeTimers();
});
- Then, when you call a deed and expect a new cargo shipment:
await button.simulate('click'); // calls a deed, so we await
jest.runAllTimers(); // flush the batch
wrapper.update(); // enzyme doesn't always see updates with hooks, this ensures it does
Make sure that you await
any deed calls, as they are async
Tips
Selectors
Use selectors: all of the subscription functions give you the option to pass in a selector function to pick which parts of the full cargo that you want to subscribe to, and you should do so unless you are using everything in cargo.
Why? If the cargo you define in your selector does not change, your component won't get told to rerender. That's a nice performance win.
Debug Mode
Use the debug prop for Store to show helpful logs in the console. When your deeds are invoked and through different points in the update cycle, you'll see colorful logs that will help you troubleshoot.
Skip Shipment
If your deed's thatDoes
or thenDoes
doesn't need to update cargo, call the extra skipShipment()
to skip the update process. Useful for side-effects, or as a performance optimization.
API
<Store cargo={cargo: object} deeds={deeds: array}>
Store component that publishes cargo updates, registers deeds, provides context
Store files should include
.store
in the filename
Additional Props:
name: string | (generatedID: string) => string
- define a named store, can be subscribed to directly with useNamedStore
or withNamedStore
. When a function is used, the unique generated ID is passed as the first argument
debug: boolean
- toggle debug mode, which gives you colorful console messages to help you understand what's happening
customFetch: function
- If you need to control the method actually making the API request, you can pass in a custom function to do it yourself.
batchTime: number(ms)
- Control the length of time that the store will allow for multiple cargo shipments to be batched into a single shipment
sync: object
- Pass an object whose values should be synchronized with the store's cargo. When a value changes, the new value is put into the store's update queue
Static Properties
Store.defaultFetchResponse
: Change how the Store automatically handles and API response. If you want custom redirection or to change the default method, you can change this to an async
function of your choosing
Store.defaultFetchOptions
: Include Fetch options into calls by default. Great for setting up tokens or cors for all your calls
Store.baseUrl
: Set the base URL that API paths will be appended to
useStore(selector) => ({cargo: {}, deeds: {}})
Can only be used with functional components. Subscribes to the closest ancestor Store.
selector: (cargo) => ({slice: cargo.slice}) - Only listens to the selected portion of cargo
Subscribed component files should include
.sub
in the filename
useNamedStore(name, selector) => ({cargo: {}, deeds: {}})
Can only be used with functional components. Subscribes to the named ancestor Store(s).
Can only subscribe to ancestor Stores, not sibling Stores
name: string | string[] - The name of the store(s) you want to subscribe to
Note that the cargo and deeds from each store will be merged in the order you define
selector: (cargo) => ({slice: cargo.slice}) - Only listens to the selected portion of cargo
Subscribed component files should include
.sub
in the filename
withStore(selector)(Component) => <Component cargo={cargo: object} deeds={deeds: array} />
Recommended for class components. Subscribes to the closest ancestor Store.
selector: (cargo) => ({slice: cargo.slice}) - Only listens to the selected portion of cargo
Subscribed component files should include
.sub
in the filename
withNamedStore(name, selector)(Component) => <Component cargo={cargo: object} deeds={deeds: array} />
Recommended for class components. Subscribes to the named ancestor Store(s).
Can only subscribe to ancestor Stores, not sibling Stores
name: string | string[] - The name of the store(s) you want to subscribe to
Note that the cargo and deeds from each store will be merged in the order you define
selector: (cargo) => ({slice: cargo.slice}) - Only listens to the selected portion of cargo
Subscribed component files should include
.sub
in the filename
deed.action
.called(name: string)
The name you define here is the name of the deed, what you will be invoking later
.thatDoes(function(actionExtras, ...args) => any)
The action you want to take when the deed is called.
Arguments you pass to the deed are passed into your function.
The value returned from the function you pass is merged into cargo, to be used in the next shipment
actionExtras = {
cargo: {}, // the cargo object of your store
deeds: {}, // the deeds you have registered
props: {}, // any other props passed to Store
skipShipment: function // call this if you have no cargo to update
}
deed.request
Makes an API call using the methods below. The Store currently only handles JSON responses from your API call, and will automatically pass the response body to afterwards
and/or thenDoes
.
.called(name: string)
The name you define here is the name of the deed, what you will be invoking later
.hits(path: string | function(fetchExtras, ...args))
The url (relative or absolute) that you want to make the api request to, or a function that returns that url
fetchExtras = {
cargo: {}, // the cargo object of your store
props: {}, // any other props passed to Store
}
.withVerb(verb: string)
The HTTP Verb that you want the request to have
.withHeaders(headers: {})
The Headers you want the request to have
.withBody(function(fetchExtras, ...args) => any)
Pass a function that returns what you want the body of the request to be.
That function is invoked when the deed is called: deed.deedName(variable)
passes variable
into the function
fetchExtras = {
cargo: {}, // the cargo object of your store
props: {}, // any other props passed to Store
}
.withJSON(function(fetchExtras, ...args) => object)
Pass a function that returns an object of what you want the body of the request to be.
That function is invoked when the deed is called: deed.deedName(variable)
passes variable
into the function
The returned object will automatically be called with JSON.stringify
.
Using this method will automatically set header content-type
to application/json; charset=utf-8
, but can be overriden with withHeaders
.
fetchExtras = {
cargo: {}, // the cargo object of your store
props: {}, // any other props passed to Store
}
.withQueryParams(function(fetchExtras, ...args) => {})
Pass a function that returns the key value pairs you want to be converted to a queryString and appended to the path
from .hits
That function is invoked when the deed is called: deed.deedName(variable)
passes variable
into the function
fetchExtras = {
cargo: {}, // the cargo object of your store
props: {}, // any other props passed to Store
}
.withConfig(function(fetchExtras, ...args) => {})
Pass a function that configures all the options that fetch can handle.
Useful for configuring things like cors
or if you want total control of the config
That function is invoked when the deed is called: deed.deedName(variable)
passes variable
into the function
fetchExtras = {
cargo: {}, // the cargo object of your store
props: {}, // any other props passed to Store
}
.afterwards(function(resExtras, res) => {})
Pass a function that is executed after a request is successful, this is called before .thenDoes
if present
The value returned from the function you pass is NOT merged into cargo, it is passed to
thenDoes
resExtras = {
deeds: {}, // the deeds you have registered
props: {}, // any other props passed to Store
cargo: {}, // the cargo object of your store
}
.catchError(function(resExtras, e) => void)
Pass a function to handle when a request fails. You can use extras.deeds to call a deed to set a loader or error message or something
resExtras = {
deeds: {}, // the deeds you have registered
props: {}, // any other props passed to Store
cargo: {}, // the cargo object of your store
}
.thenDoes(function(actionExtras, res) => any)
The action you want to take when the request has returned, this is called after .afterwards
if present
The value returned from the function you pass is merged into cargo
actionExtras = {
cargo: {}, // the cargo object of your store
deeds: {}, // the deeds you have registered
props: {}, // any other props passed to Store
skipShipment: function // call this if you have no cargo to update
}
deed.flow
.called(name: string)
The name you define here is the name of the deed, what you will be invoking later
.thatStartsWith(deed | deed[])
The first deed or array of deeds that will be called with the arguments passed during invocation of the deed. By default, the flow will advance on 'done'
.
Calling
.withOriginalArgs()
does nothing here, since the first deeds will always get arguments passed from invocation
.andThenCalls(deed | deed[])
Queues the next deed or array of deeds to be called once the previous deeds advance.
.whichAdvancesOn("done" | "shipment")
Control when the previous deed or deed array should advance to the next set.
'done'
advances when:- A
RequestDeed
has finished executingthenDoes
if applicable, or after.afterwards
otherwise - An
ActionDeed
has finished executingthatDoes
- A
FlowDeed
has finished its flow
- A
'shipment'
advances when:- After a
RequestDeed
callsthenDoes
and its cargo is shipped, or after.afterwards
otherwise - After an
ActionDeed
callsthatDoes
and its cargo is shipped - A
FlowDeed
has finished its flow
- After a
Here's a diagram showing when each trigger is called:
ActionDeed RequestDeed FlowDeed
| | |
thatDoes() makes API call Calls all items in Flow
| - - - "done" | | - - - "done"
Store Ships cargo afterwards() | - - - "shipment"
| - - - "shipment |
thenDoes())
| - - - "done"
Store Ships cargo
| - - - "shipment"
.withOriginalArgs()
Causes this stage of the flow to use the arguments passed in the flow's invocation
.whichMapsTo(function(previousResult) => nextArgs)
Transforms the result of the stage before passing it along
May be useful when the stage calls an array of deeds, to map the array of results into a single value
combineDeeds(deed[] | deed | deed{})
Utility function to combine deeds from multiple sources
const deeds = combineDeeds(arrayOfDeeds, importedModuleWithDeeds, request.deed, [action.deed], etc...)
Test Utils
You should probably write tests for your application, especially when it's as easy as using these tools.
Take a look in
examples/form-example
for real tests showcasing different flows
Approach
You are free to write whatever type of test you want, but here are some recommended ways to use the provided test utilities.
Test your deeds in isolation with mock
mock
allows you to unit test each call of a deed, keeping your tests pure and simple.
Usage:
Given this deed
const someActionDeed = deed.action
.called('action')
.thatDoes((extras, x) => ({ data: x })
Use the following in your test
mock
.thisCall('thatDoes') // the call that you are testing
.fromThisDeed(someActionDeed)
.withArgs('foo')
.thenAssert(result => expect(result.data).toEqual('foo'));
Test your subscribers with stub
stub
provides methods to replace either or both deeds or stores with the logic of your choosing.
- Use
stub.stores
to control exactly which deeds and cargo are passed to your subscribers, without worrying about batching, store names, or heirarchy - Use
stub.thisDeed
to replace a deed's invocation with a function that you provide, giving maximum control over your tests
Usage:
Given this sample
const SomeComponent = () => {
const { cargo, deeds } = useNamedStore(['store1', 'store2']);
useEffect(() => {
deeds.onLoad(); // sample deed call
}, []);
return (
<span>
{cargo.label}: {cargo.value}
</span>
);
};
Your tests might look like this
// inside a test block, after calling gin.mockStores()
// setup stubbed deed
const testFn = jest.fn();
const stubOnLoad = stub.thisDeed('onLoad').withThis(testFn);
// setup stubbed stores
stub.stores
.withCargo({
label: 'Count',
value: 2,
})
.withDeeds([stubOnLoad]);
// using enzyme's mount method
const wrapper = mount(<SomeComponent />);
// useEffect calls on mount
expect(testFn).toHaveBeenCalled();
expect(wrapper.text()).toEqual('Count: 2');
TL;DR
When you're testing your deed logic, use mock
, when you're testing your subscribers, use stub
.
mock
You wrote some awesome deeds and want to make sure they work forever, just use mock
to easily do that. The API is the same for any type of deed.
Usage
Given this deed
const someActionDeed = deed.action
.called('action')
.thatDoes((xt, arg1) => ({
data: {
...xt.cargo.data,
...arg1
},
})
Use the following in your test
mock
.thisCall('thatDoes')
.fromThisDeed(someActionDeed)
.withExtras({
cargo: {
data: {
foo: 'bar
},
},
})
.withArgs({ test: true })
.thenAssert(result => expect(result.data).toEqual({ foo: 'bar', test: true }))
mock
API
.thisCall(method: string)
Pass in the deed method you want to mock
Does not support methods like
withVerb
,withHeaders
, orcalled
because they are given constants
.fromThisDeed(deed: ActionDeed | RequestDeed)
Pass in the deed you want to mock, can be either type.
.withExtras(extras: ActionExtras | RequestExtras | FetchExtras)
Optional
Pass the extras argument your method expects.
.withArgs(...args)
Optional
Pass in additional arguments that your method expects.
.thenAssert(assertFunction(result) : void)
Calls your method with the mock arguments you've defined, and passes the result to the assertFunction
you define here. Typically you'd assert that the result of the call is what you'd expect.
.withArgs(...args)
Optional
Pass in additional arguments that your method expects.
.atThisStage(stage: number)
Only for FlowDeeds, defines which stage the mock should be testing against, zero indexed.
stub
If you need more control for your component test, use stub
to quickly
replace a deed or your stores with a stubbed version.
Examples
Stubbed deeds:
// Component.sub.tsx
const Child = () => {
const { deeds } = useStore();
return <button onClick={deeds.requestDeed} />;
};
// store-logic.ts
const requestDeed = deed.request
.called('requestDeed')
.hits('/test')
.withVerb('GET')
.withQueryParams((extras, e) => ({ [e]: true }));
// tests.tsx
const testFn = jest.fn();
const testDeed = stub.thisDeed('requestDeed').withThis(testFn);
const wrapper = mount(
<Store cargo={{}} deeds={[testDeed]}>
<Child />
</Store>,
);
wrapper.find('button').simulate('click');
expect(testFn).toHaveBeenCalled();
In that example we passed a string to thisDeed
, but we can also pass the deed itself and have the same functionality: thisDeed(requestDeed)
.
Stubbed stores:
// Component.sub.tsx
const Child = () => {
const { deeds, cargo } = useStore();
return <button id={cargo.id} onClick={deeds.requestDeed} />;
};
// store-logic.ts
const requestDeed = deed.request
.called('requestDeed')
.hits('/test')
.withVerb('GET')
.withQueryParams((extras, e) => ({ [e]: true }));
// tests.tsx
const store = stub.stores
.withCargo({ id: 'id' })
.withDeeds([requestDeed])
.andExpose();
const wrapper = shallow(<Child />);
wrapper.find('button').simulate('click'); // calls requestDeed
expect(store.calls.requestDeed.count).toEqual(1);
expect(wrapper.prop('id')).toEqual('id');
In that example we passed a real deed to withDeeds
, but we can also pass just the deed name, or even a stubbed deed - which
will retain the stubbed implementation.
stub
API
for deeds
.thisDeed(deedOrName: string | Deed)
Pass in the deed you want to mock, can be either type, or just pass the name of the deed.
.withThis(testFunction())
Define a function that you want to be invoked when the deed is called. No arguments are passed.
for stores
.stores
Exposes the methods below to provide the stubs for your store. Replaces and combines all Stores, no matter how many stores your subscriber is subbed to.
Checkout stub setup before using
.withCargo(cargo: {})
Passes the provided cargo down to subscribers
Usage:
stub.stores
.withCargo({foo: 'bar'});
.withDeeds(deeds: (string | Deed)[], override?: {})
deeds
must be an array, but can contain string names of deeds and/or actual deed definitions and/or stub deeds
If a stub deed is used, the implementation defined with
withThis
will be used when the deed is invoked. Otherwise the default implementation for the passed-down deeds is a noop
override
can be used to change the default implementation of the deeds you pass down
Usage:
stub.stores
.withDeeds(['deedName', 'otherDeed'],
{
deedName: () => `this is called instead`,
}
);
// otherDeed will still have the default noop implementation
.andExpose() => TestStore
Used to gain access to the TestStore
Usage:
const store = stub.stores
.withCargo({foo: 'bar'})
.andExpose();
// otherDeed will still have the default noop implementaiton
TestStore.calls
The calls property exposes information about the calls to the deeds that you defined in stub.stores.withDeeds
Usage
// get the call count for a deed called "deedName"
store.calls.deedName.count
// get the arguments from a deed called "deedName" on the second call
store.calls.deedName.0.args
.reset()
Clears the cargo, deeds, and calls data from the TestStore
Useful to cleanup between tests
Example Usage
// recommended usage
afterEach(stub.stores.reset);
// per test
it('some test', () => {
// setup
const store = stub.stores.withDeeds(['deedName']);
// test assertions
stub.stores.reset();
// stub.stores can be setup again
});
.resetCalls()
Clears calls data from stub.stores
Useful if the same deeds are used across tests.
Example Usage
// recommended usage if deeds are shared between tests
afterEach(stub.stores.resetCalls);
// per test
it('some test', () => {
// setup
const store = stub.stores.withDeeds(['deedName']);
// test assertions
stub.stores.resetCalls();
// store.calls is reset
});
Stub Setup
In order to use stub.stores
you must at minimum call mockStores()
before use.
Below are additional calls to help control the TestStore
mockStores()
Prepares gin
to allow use of stub.stores
Example Usage
import { mockStores } from '@artisnull/gin';
// recommended usage
beforeAll(mockStores);
// per test
it('some test', () => {
mockStores();
// test assertions
});
unmockStores()
Reverts gin
for normal use
Example Usage
import { mockStores, unmockStores } from '@artisnull/gin';
// recommended usage
beforeAll(mockStores);
afterAll(unmockStores);
// per test
it('some test', () => {
mockStores();
// test assertions
unmockStores();
});
FAQ's
What is the difference between thatDoes
and thenDoes
?
They are similar: the returned value from both is used to queue a new cargo shipment
deed.action.thatDoes
is called immediately when you call your action deed, and whatever aguments you pass to that deed are passed into thatDoes
.
deed.request.thenDoes
is called after your api request resolves successfully with the data from the api as the first argument.
If you use
afterwards
, that is called with the api data first, with the value returned fromafterwards
now being passed tothenDoes
. - apiResponse -> afterwards -> thenDoes