Instantly share code, notes, and snippets.

Embed
What would you like to do?
Reselect par le test
/*
# Reselect par les tests
Samuel Bouchet
Freelance - Game developer (A Game Studio)
@Lythom | samuel-bouchet.fr | a-time-paradox.com
---
Introduction :
Postulat: un state immuable, une vue connectée qui reflète l'état du state
// Immutable state
const aState = {
list: ['a', 'ab', 'c']
};
// Un composant qui fait le rendu d'une partie du state
const AComponent = ({alistFromState}) => (
<ul>
{alistFromState.map((element, i) =>
<li key={i}>{element}</li>
)}
</ul>
);
// Un sélecteur pour extraire une partie d'une state
function selectPropsFromState(state) {
return {
listFromState: state.list.filter(element => element.startsWith('a'))
}
}
// Et d'une manière ou d'une autre le composant est connecté au state
// et un rendu est re-calculé tel que à chaque changement d'état,
// on constitue les nouvelles props et on force un re-rendu si les props ont changé.
const props = selectPropsFromState(state);
if (!allPropsAreStriclyEqual(previousProps, props)) {
render(<AComponent {...props} />)
}
// égalité strict => Si la référence est différente, la valeur est considérée différente.
// Exemple: si on utilise React+Redux, c'est la fonction "connect" de react-redux a ce rôle.
Fin de l'intro, on commence !
// Donc on veut que si la valeur est la même, la référence soit la même.
// mais avec .filter() dans le sélecteur le résultat est toujours une nouvelle référence
// même si le table de base est identique.
*/
// Fin de l'intro, on commence !
// import {createSelector} from 'reselect';
const {createSelector, defaultMemoize, createSelectorCreator} = require('reselect');
// Etat qui va nous servir pour les tests
const state = {
ui: {
menuOpen: false,
platformFilter: 'PC',
},
fetchedData: {
games: [
{
name: 'A Time Paradox',
platform: ['android', 'PC'],
description: 'Coincé dans une boucle temporelle, ' +
'infitrez-vous contre vous-même pour éviter les paradoxes'
},
{
name: 'The Rune Maker',
platform: ['android'],
description: 'Combinez les runes pour créer des armes ' +
'beaucoup trop puissantes.'
}
]
}
};
// fonctions utilitaires pour manipuler les données
const filterGamesByPlatform = (games, platformName) => games.filter(game => game.platform.indexOf(platformName) > -1)
const filterAndroidGames = (games) => filterGamesByPlatform(games, 'android');
describe('reselect', function () {
it('memoizes the result of a selector', function () {
// le sélecteur récupère une sous partie des données
const selectAndroidGames = state => filterAndroidGames(state.fetchedData.games);
// const selectAndroidGames = createSelector(
// state => state.fetchedData.games,
// games => filterAndroidGames(games)
// );
const firstSelection = selectAndroidGames(state);
const secondSelection = selectAndroidGames(state);
expect(firstSelection).toBe(secondSelection);
});
it('invalidates cache if the input parameters is different', function () {
const selectAndroidGames = createSelector(
state => state.fetchedData.games,
games => filterAndroidGames(games)
);
const selectionOnState = selectAndroidGames(state);
// on vérifie que si le state est différent alors le cache est bien invalidé
const nextState = state;
// const nextState = immutablyAddGame(state, {name: 'Other Game', platform: ['PC']});
const selectionOnNextState = selectAndroidGames(nextState);
// Input différent = résultat différent
expect(selectionOnState).not.toBe(selectionOnNextState);
});
// Si les paramètres d'entrées sont le même, on utilise le cache, même si le state a changé par ailleurs
it('preserves cache if the input parameters is the same, even if other part of the state changed', function () {
const getMenuEntries = menuOpen => menuOpen ? ['Close Menu', 'Games', 'About'] : ['Open Menu'];
const selectMenuEntries = createSelector(
state => state.ui.menuOpen,
menuOpen => getMenuEntries(menuOpen)
);
const selectionOnState = selectMenuEntries(state);
// un state qui n'a même pas la partie "games" ! Mais comme menuOpen de nextState a
// la même valeur que menuOpen de state, pour reselect c'est le même paramètre en cache.
const nextState = {ui: {menuOpen: false}};
// const nextState = {ui: {menuOpen: false}};
const selectionOnNextState = selectMenuEntries(nextState);
expect(selectionOnState).toBe(selectionOnNextState);
});
it('memoizes only one result', function () {
const selectAndroidGames = createSelector(
state => state.fetchedData.games,
games => filterAndroidGames(games)
);
const firstSelectionOnState = selectAndroidGames(state);
const nextState = immutablyAddGame(state, {name: 'Other Game', platform: ['PC']});
// la deuxième sélection invalide le cache
const selectionOnNextState = selectAndroidGames(nextState);
// select the intial state again
const secondSelectionOnState = selectAndroidGames(state);
expect(firstSelectionOnState).not.toBe(selectionOnNextState);
expect(firstSelectionOnState).toBe(secondSelectionOnState);
//expect(firstSelectionOnState).not.toBe(secondSelectionOnState);
});
it('can take several parameters as input', function () {
const selectFilteredGames = createSelector(
state => state.fetchedData.games,
state => state.ui.platformFilter,
(games, platformName) => filterGamesByPlatform(games, platformName)
);
const firstSelectionOnState = selectFilteredGames(state);
const secondSelectionOnState = selectFilteredGames(state);
expect(firstSelectionOnState).toBe(secondSelectionOnState);
// platformFilter vaut "PC". Il n'y a qu'un seul jeu PC.
expect(firstSelectionOnState.length).toBe(1)
});
it('can take dynamic parameters as input', function () {
const selectFilteredGames = createSelector(
state => state.fetchedData.games,
(state, secondParam) => secondParam, // la signature ici est différente de précédemment
(games, platformName) => filterGamesByPlatform(games, platformName)
);
const firstSelectionOnState = selectFilteredGames(state, 'android');
const secondSelectionOnState = selectFilteredGames(state, 'android');
expect(firstSelectionOnState).toBe(secondSelectionOnState);
});
it('still memoizes only one result', function () {
const selectFilteredGames = createSelector(
state => state.fetchedData.games,
(state, secondParam) => secondParam,
(games, platformName) => filterGamesByPlatform(games, platformName)
);
const firstSelectionOnState = selectFilteredGames(state, 'android');
const secondSelectionOnState = selectFilteredGames(state, 'PC');
const thirstSelectionOnState = selectFilteredGames(state, 'android');
expect(firstSelectionOnState).not.toBe(secondSelectionOnState);
expect(firstSelectionOnState).toBe(thirstSelectionOnState);
// expect(firstSelectionOnState).not.toBe(thirstSelectionOnState);
// créer autant de sélecteurs spécialisés que nécessaires,
// ou alors... avoir tous les paramètres dans le state,
// ou alors...
});
it('allows memoization contexts using "makeSelector" pattern', function () {
const makeSelectFilteredGames = () => createSelector(
state => state.fetchedData.games,
(state, secondParam) => secondParam,
(games, platformName) => filterGamesByPlatform(games, platformName)
);
// Créer un context par composant
// react-redux permet d'initialiser ce contexte par composant en utilisant "makeMapStateToProps"
const selectFilteredGamesForComponent1 = makeSelectFilteredGames();
const selectFilteredGamesForComponent2 = makeSelectFilteredGames();
const firstSelectionOnState = selectFilteredGamesForComponent1(state, 'android'); // used by component 1
const secondSelectionOnState = selectFilteredGamesForComponent2(state, 'PC'); // used by component 2
const thirstSelectionOnState = selectFilteredGamesForComponent1(state, 'android'); // sed by component 1
expect(firstSelectionOnState).not.toBe(secondSelectionOnState);
expect(firstSelectionOnState).not.toBe(thirstSelectionOnState);
// expect(firstSelectionOnState).toBe(thirstSelectionOnState);
// ou alors...
});
// import createCachedSelector from 're-reselect';
const {default: createCachedSelector} = require('re-reselect');
it('can be cached smartly using re-reselect', function () {
const selectFilteredGames = createCachedSelector(
state => state.fetchedData.games,
(state, secondParam) => secondParam,
(games, platformName) => filterGamesByPlatform(games, platformName)
)(
// Fonction resolver.
// La clé de cache doit être une chaine de caractère
(state, secondParam) => secondParam
);
const firstSelectionOnState = selectFilteredGames(state, 'android'); // used by component 1
const secondSelectionOnState = selectFilteredGames(state, 'PC'); // used by component 2
const thirstSelectionOnState = selectFilteredGames(state, 'android'); // sed by component 1
expect(firstSelectionOnState).not.toBe(secondSelectionOnState);
expect(firstSelectionOnState).not.toBe(thirstSelectionOnState);
// expect(firstSelectionOnState).toBe(thirstSelectionOnState);
});
it('can get complex', function () {
const filterGamesByCriteria = (games, platform, releasedAfter, releasedBefore) => games.slice(0);
const exampleOfComplexSelector = createCachedSelector(
state => state.fetchedData.games,
(_, startDate) => startDate,
(_, __, endDate) => endDate,
(_, __, ___, platform) => platform,
(games, releasedAfter, releasedBefore, platform) => (
filterGamesByCriteria(games, platform, releasedAfter, releasedBefore)
)
)(
(games, startDate, endDate, platform) => `${startDate}:${endDate}:${platform}`
);
const firstSelectionOnState = exampleOfComplexSelector(state, '2018-03-01', '2018-07-01', 'android');
const secondSelectionOnState = exampleOfComplexSelector(state, '2018-03-01', '2018-07-01', 'android');
expect(firstSelectionOnState).toBe(secondSelectionOnState);
});
it('can get complex (Alternative)', function () {
// selecteur avec fonction de comparaison custom (compare su un niveau)
const createDeepEqualSelector = createSelectorCreator(
defaultMemoize,
areEqual
);
const alternativeFilterGamesByCriteria = (games, {releasedAfter, releasedBefore, platform}) => games.slice(0);
const exampleOfAlternativeComplexSelector = createCachedSelector(
state => state.fetchedData.games,
(_, criteria) => criteria,
(games, criteria) => alternativeFilterGamesByCriteria(games, criteria)
)(
(games, criteria) => `${hash(criteria)}`,
// {selectorCreator: createSelector} // par défaut re-reselect utilise le createSelector de reselect
{selectorCreator: createDeepEqualSelector} // ici on spécifie un sélecteur custom
);
const firstAlternativeSelectionOnState = exampleOfAlternativeComplexSelector(state, {
releasedAfter: '2018-03-01',
releasedBefore: '2018-07-01',
platform: 'android'
});
const secondAlternativeSelectionOnState = exampleOfAlternativeComplexSelector(state, {
releasedAfter: '2018-03-01',
releasedBefore: '2018-07-01',
platform: 'android'
});
expect(firstAlternativeSelectionOnState).toBe(secondAlternativeSelectionOnState);
// Le mieux est d'avoir les critères dans le state, ainsi plus besoin de re-reselect
// better code = no code
});
});
// ATTENTION !
// Les données mémoizées peuvent être difficiles à debugger. Mettre en places les outils nécessaires
// pour valider que la fonction de génération de clé de cache (le resolver) fonctionne bien.
function immutablyAddGame(state, gameObject) {
return {
...state,
fetchedData: {
...state.fetchedData,
games: [
...state.fetchedData.games,
gameObject
]
}
};
}
function hash(data) {
return JSON.stringify(data);
}
function areEqual(o1, o2) {
let p;
for (p in o1) {
if (o1.hasOwnProperty(p)) {
if (o1[p] !== o2[p]) {
return false;
}
}
}
for (p in o2) {
if (o2.hasOwnProperty(p)) {
if (o1[p] !== o2[p]) {
return false;
}
}
}
return true;
}
@samuelbouchet

This comment has been minimized.

Owner

samuelbouchet commented Jun 14, 2018

to run, using nodejs 8+ (linux script):

mkdir reselect_test && cd reselect_test
npm i jest reselect re-reselect
curl https://gist.githubusercontent.com/samuelbouchet/8d0ab7b7fa9e212b1b2577533ea7ab2e/raw/b9d43674443888da96a26b346cc2762f63d990bf/reselect.test.js > reselect.test.js
touch jest.config.js
./node_modules/.bin/jest --watchAll
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment