Pattern for Testing Selectors
Because Javascript is not a strongly typed language, we must consider side-effects when testing. Selectors in particular are based on a hierarchy and often one selector is used as the input for another. Because we don't have the benefit of type enforcement, it is possible for the output of a selector to change structurally, which will impact downstream selectors. Because of this, it is not wise to use resultFunc to supply the inputs for all selector tests. At a minimum, the happy paths (successful test scenarios) should be tested against a full run of the selector, which includes execution of the selectors that supply it with inputs.
In order to accomplish this cleanly, we start with our associated reducers as the base and define our successful test case data by making a modified copy. This allows us to retain immutability of our data and allows us to continue to define our data in a hierarchical fashion, starting with known sensible defaults and only modifying the pieces of data that need to be modified for the particular test(s) at hand. This also has the effect of making it easier to modify data for negative testing scenarios using simple setIn calls instead of standing up new data structures to inject via resultFunc. Ultimately, our tests become higher valued, cleaner and more readable.
It is important to note that data defined in our spec's data file should be good, clean data and should result in successful test completion. Negative testing data should be defined within the tests themselves to signal intent and will be demonstrated later in this document.
Defining Test Data
This is an example from products.order.selectors.spec.data.js. The naming convention tells us that this data file belongs with the products.order.selectors.spec.js testing file. In this example, we import the base customerCare spec data and apply the reducer configuration from products.order.reducer.js as the INITIAL_STATE. This gives us an initial unpopulated data store and also a base for the remainder of our data.
Data File Setup Example
import {CUSTOMER_CARE_STORE} from './customercare.spec.data.js'; import {INITIAL_STATE} from '../products.order.reducer'; import {PRICING_PLAN_TYPES} from '../constants/pricingplan.type.constants'; import {isNil} from 'ramda'; export const DEFAULT_PRODUCT = createProduct(1234); export const DEFAULT_PRODUCT_PATH = ['customercare', 'productOrder', 'data', 'productsWithMetadata', '1234'];
Also note the two additional helper constants that we've created. The DEFAULT_PRODUCT constant give us a default product to be used for our store when we need to populate it with a product. We create the default product with a helper method that accepts the product Id as a parameter. Because of this, when we create a populated store and build upon that data later, we have a default product with known values we can use to reference it without the need to recreate it for every test. The DEFAULT_PRODUCT_PATH constant gives us the path to that product in the store so we can easily reference it later. These types of helpers may or may not be necessarily and can vary depending on your testing scenario, but consider them when common pieces of data will be used across multiple tests. It not only creates consistency between tests, but eases refactoring when data structures change as well.
This is an example of the setup for the INITIALIZED_STORE constant. For simple cases you can define your constants inline. In this case, much of the data has a bit more complex setup so we've chosen to use methods to initialize the data. Note how we created a default empty customer, not creating any data that's not necessary for any of our tests, and utilize setIn to combine our customer and our INITIAL_STATE with the CUSTOMER_CARE_STORE constant to create our initialized store data and avoid unnecessary duplication of json.
Data Setup Example
export const INITIALIZED_STORE = initializeInitialStore(); function initializeInitialStore() { const customer = { selectedCustomer: { subscriptions: {} } }; return CUSTOMER_CARE_STORE .setIn(['customercare', 'customer'], customer) .setIn(['customercare', 'productOrder'], INITIAL_STATE); }
From there, we are able to continue defining our data based on hierarchy. We now have an initialized store we can use to execute our tests when no product data exists and we can further define our store by adding the default product as follows:
Further Defining Data Hierarchy
export const POPULATED_STORE = initializePopulatedStore(); function initializePopulatedStore() { const productOrderData = { availableProducts: { data: { productsMap: { 1234: DEFAULT_PRODUCT }, productsDisplayOrder: [DEFAULT_PRODUCT.Id], pageNumber: 1, recordCount: 1, searchString: '', selectedProductId: DEFAULT_PRODUCT.Id } }, productsWithMetadata: { 1234: DEFAULT_PRODUCT }, shoppingCart: { Items: [] }, shoppingCartProductsMetadata: {} }; return INITIALIZED_STORE.setIn(['customercare', 'productOrder', 'data'], productOrderData); }
As you can see, we've further built our data by using the INITIALIZED_STORE as a starting point and adding in our base product data to define POPULATED_STORE.
Leveraging Our Test Data
With the test data defined as above, it is now easy to import, expand upon and leverage for both our positive and negative testing purposes and the separation of data from test code keeps our tests clean. We can utilize the data we've created to produce our successful test scenarios and modify bits of that data within the tests to form our negative testing scenarios. This abbreviated example demonstrates importing our data and performing both positive and negative testing.
Testing Example
import * as selectors from './products.order.selectors'; import * as TestData from './products.order.selectors.spec.data.js'; import Immutable from 'seamless-immutable'; import { expect } from 'chai'; describe('IsCalculatingQuoteSelector with initial store', () => { it('should return false', () => { const appStore = TestData.INITIALIZED_STORE; const response = selectors.IsCalculatingQuoteSelector(appStore); expect(response).to.be.false; }); }); describe('IsCalculatingQuoteSelector while calculating order quote', () => { it('should return true', () => { const appStore = TestData.POPULATED_STORE.setIn(['customercare', 'productOrder', 'isCalculatingQuote'], true); const response = selectors.IsCalculatingQuoteSelector(appStore); expect(response).to.be.true; }); });
As you can see, we've created an initial test based on our INITIALIZED_STORE constant. Next, we created an additional test, this time using our POPULATED_STORE constant, but leveraging a setIn to modify the isCalculatingOrderQuote property. This allows us to make simple data changes without muddying our test files with verbose blocks of json. It makes it immediately clear to future maintainers exactly what the data modification is and it keeps maintainability high in that structural changes are made in the test data file and only setIn paths must be updated for the tests to be correct.