Tiene un minuto para hablar de TDD
Sergio Marin
@highercomve
uBiome
¿De que queremos hablar ?
¡Queremos hablar de TDD!
¡Bien! Pero como esto es Javascript,
¿Con que framework lo hacemos?
Eso
Entonces como yo tome la iniciativa
vamos a usar
- Vue
- Vuex
- Nuxt
- Feathers
- Ava
¿Por que ?
Nuxt es un framework que usa Vue y Vuex para crear aplicaciones Web y viene con Server Side rendering out of the box.
Feathers
Por lo tanto unidos tenemos una muy buena herramienta
¿Por que?
Vue es una libreria para creacion de aplicaciones web clientes como lo es Angular o React.
La sintaxis de Vue en general es bastante familiar a lo que conocemos de HTML, CSS, Javascript. Usando vue-loader para webpack tenemos un resultado como este.
<template>
<section>
<p v-if="error" class="help is-danger">
{{error}}
</p>
<form v-on:submit.prevent="create">
<div class="field">
<p class="control has-icons-left has-icons-right">
<input name="email" v-model="email" class="input" type="email" placeholder="Email">
<span class="icon is-small is-left">
<i class="fa fa-envelope"></i>
</span>
<span class="icon is-small is-right">
<i class="fa fa-check"></i>
</span>
</p>
</div>
<div class="field">
<p class="control has-icons-left">
<input name="password" v-model="password" class="input" type="password" placeholder="Password">
<span class="icon is-small is-left">
<i class="fa fa-lock"></i>
</span>
</p>
</div>
<div class="field">
<p class="control">
<button type="submit" class="button is-success submit-form" v-bind:class="{ 'is-loading': loading }">Login</button>
</p>
</div>
</form>
</section>
</template>
<script>
function mapValueToStore (obj, field) {
obj[field] = {
get () {
return (!!this.storeNamespace) ?
this.$store.state[this.storeNamespace].loginData[field] :
this.$store.state.loginData[field];
},
set (value) {
const storeNamespace = (!!this.storeNamespace) ? `${this.storeNamespace}/` : '';
return this.$store.commit(`${storeNamespace}setLoginData`, { [field]: value });
}
}
return obj;
}
export default {
props: {
storeNamespace: {
type: String
},
successCb: {
type: Function,
default: () => {}
},
errorCb: {
type: Function,
default: () => {}
}
},
computed: {
...['email', 'password', 'error', 'loading'].reduce(mapValueToStore, {})
},
methods: {
create () {
const storeNamespace = (!!this.storeNamespace) ? `${this.storeNamespace}/` : '';
return this.$store.dispatch(`${storeNamespace}authenticate`)
.then(this.successCb)
.catch(this.errorCb);
}
}
}
</script>
¿Por que? - Vuex
Vuex es una libreria para manejo de estado como lo es Redux en React. Manejo de estados de manera unidireccional
Lo cual permite mantener una sola fuente de la verdad en nuestra aplicacion y facilita hacer test de nuestros cambios de estado.
¿Por que? - Vuex
export const state = () => {
return {}; // El estado inicial del store.
};
export const actions = {};
export const mutations = {};
export const getters = {};
// Otra manera de crear un store puede ser por medio de un factory el cual injecte dependencias como
export default function factory (loginFunc , signupFunc, getProfileFunc) {
// Aqui tenemos disponibles 3 funciones que podemos usar como funciones para hacer el login, signup y get profile
// de manera que este mismo store funcione en distintos ambientes con api distintos.
return {
plugins: [],
state: intitialState,
actions: {},
mutations: {},
getters: {}
};
¡Pero antes! ....
Cual es el amor con TDD (Test Driven Development), BDD (Behavior Driven Development) y todas las cosas que terminen en DD
El fin ultimo es lo mismo, es que las cosas bien definidas, bien estructuradas y que no tengamos que asegurarnos manualmente que cualquier cosa que cambiemos nos ropa todo lo que hemos hecho anteriormente.
¡Eso es lo mas importante de todo!
Que no vayas a tocar un app y tengas el miedo que un cambio va a desencadenar una explosion en cadena.
¿Como hacer TDD?
Esto es lo mas importante, definir como hacer TDD.
Pero vamos a intentar resumir unos simples tips.
PD: No soy
Tip 0
Escribe la prueba primero
La respuesta es no
Imaginen que la prueba que estas escribiendo es la definicion de nuestra feature o solucion que queremos implementar.
PD: Esto no siempre sera facil, asi que algunas veces este tip lo vamos a romper
Tip 1
Comienza probando las cosas mas faciles de probar
Generalmente nuestro primer error es querer probar todo el APP desde que inicia hasta los click del usuario y demas.
Tip 1
Funciones sencillas y puntuales
Imaginemos que queremos hacer una funcion que filtre sobre un Array de Todos. Esa funcion recibe dos parametros la lista de todos y una funcion que sera la encagada de filtrar, por lo tanto las features minimas que esperamos son:
Tip 1
Funciones sencillas y puntuales
import test from 'ava'
import filterTodoBy from '../src/filter_todo_by'
const list = [
{name: 'tarea 1', done: false},
{name: 'tarea 2', done: true},
{name: 'tarea 3', done: true}
]
test('With no filter return the same', t => {
const filtered = filterTodoBy(list)
t.deepEqual(filtered, list)
})
test('With an filter that is not a function', t => {
const error = t.throws(() => {
const filtered = filterTodoBy(list, 'no una funcion')
}, Error)
t.is(error.message, 'filter must be a function')
})
test('With filter done true', t => {
const filter = (todo) => todo.done === true
const filtered = filterTodoBy(list, filter)
t.deepEqual(filtered, [
{name: 'tarea 2', done: true},
{name: 'tarea 3', done: true}
])
})
test('With filter done false', t => {
const filter = (todo) => todo.done === false
const filtered = filterTodoBy(list, filter)
t.deepEqual(filtered, [
{name: 'tarea 1', done: false}
])
})
Tip 1
Funciones sencillas y puntuales
export default function filterTodoBy (todoList, filter) {
if (!filter) {
return todoList
}
if (!!filter && typeof filter !== 'function') {
throw new Error('filter must be a function')
}
return todoList.filter(filter)
}
Tip 2
Comenzemos por el store (En el caso de Vuex)
Si usamos Vuex (o cualquier cosa que sirva para manejar estados)
¡Primero!
Voy a generar un esqueleto de un paquete Vue y ahi vamos viendo como probar.
Eso trae un helper para pruebas en Vue y usa ava.
Probemos un store para Auth
-
Guardamos el JWT, user data, formulario login data y formulario de rear usuario data
-
set del
jwt ,borrar eljwt -
set del user,
borrar todo el user -
set del
loginData , borrartoda la data -
set del
createData , borrar el form -
authenticar
el usuario contra el API -
crear elusuario contra el API
const ERROR_INVALID_PARAMETERS_ON_FACTORY = 'The store factory need add login function, signup function and getProfile function';
export default function factory (loginFunc , signupFunc, getProfileFunc) {
if (typeof loginFunc !== 'function' || typeof signupFunc !== 'function' || typeof getProfileFunc !== 'function') {
throw new Error(ERROR_INVALID_PARAMETERS_ON_FACTORY);
}
return {
plugins: [],
state: intitialState,
actions: {},
mutations: {},
getters: {}
};
}
function intitialState () {
return Object.assign({}, {
jwt: '',
user: {},
loginData: {
email: '',
password: '',
error: null,
loading: false
},
signupData: {
email: '',
password: '',
error: null,
loading: false
}
});
}
Nuestro pequeño Store
import test from 'ava';
import * as AuthStore from '../../src/components/store.babel';
const ERROR_INVALID_PARAMETERS_ON_FACTORY = 'The store factory need add login function, signup function and getProfile function';
const DEFAULT_STATE = {
jwt: '',
user: {},
loginData: {
email: '',
password: '',
error: null,
loading: false
},
signupData: {
name: '',
email: '',
password: '',
error: null,
loading: false
}
};
const USER_DATA = {
name: 'Sergio Marin',
email: 'higher.vnf@gmail.com',
password: 'supersecreto'
};
const TOKEN = 'TOKEN_MOCK';
const storeMock = AuthStore.default(
(email, password) => {
if (!email && !password) {
return Promise.reject(new Error('invalid credentials'));
} else if (email === 'invalid@email.com') {
return Promise.reject(new Error('invalid email'));
} else {
return Promise.resolve(TOKEN);
}
},
({name, email, password}) => {
if (!email || !password || !name) {
return Promise.reject(new Error('you need name, email and password'));
} else if (email === 'invalid@email.com') {
return Promise.reject(new Error('invalid email'));
} else {
return Promise.resolve({user_id: 'user_id', name, email, password});
}
},
() => {
return Promise.resolve({
...USER_DATA,
user_id: 'user_id'
});
}
);
function factoryCommitDispacth (state) {
const commit = (type, payload) => storeMock.mutations[type](state, payload);
return {
commit,
dispatch (type, payload) {
return storeMock.actions[type]({ commit, state }, payload);
}
};
}
test('factory need two functions', t => {
const error = t.throws(() => {
AuthStore.default();
});
t.is(error.message, ERROR_INVALID_PARAMETERS_ON_FACTORY);
});
test('factory works ', t => {
const store = AuthStore.default(() => {}, () => {}, () => {});
t.deepEqual(store.state(), DEFAULT_STATE);
t.not(store.actions, null);
t.not(store.mutations, null);
t.not(store.getters, null);
});
test('mutate jwt', t => {
const state = storeMock.state();
storeMock.mutations.setJwt(state, TOKEN);
t.is(state.jwt, TOKEN);
});
test('mutate user', t => {
const state = storeMock.state();
storeMock.mutations.setUser(state, USER_DATA);
t.deepEqual(state.user, USER_DATA);
});
test('mutate loginData', t => {
const state = storeMock.state();
let newData = { email: 'email@email.com' };
storeMock.mutations.setLoginData(state, newData);
t.deepEqual(state.loginData.email, newData.email);
});
test('mutate signupData', t => {
const state = storeMock.state();
let newData = { email: 'email@email.com' };
storeMock.mutations.setSignupData(state, newData);
t.deepEqual(state.signupData.email, newData.email);
});
test('clear jwt', t => {
const state = storeMock.state();
storeMock.mutations.setJwt(state, null);
t.is(state.jwt, DEFAULT_STATE.jwt);
});
test('clear user', t => {
const state = storeMock.state();
storeMock.mutations.setUser(state, null);
t.deepEqual(state.user, DEFAULT_STATE.user);
});
test('clear loginData', t => {
const state = storeMock.state();
storeMock.mutations.setLoginData(state, null);
t.deepEqual(state.loginData, DEFAULT_STATE.loginData);
});
test('clear signupData', t => {
const state = storeMock.state();
storeMock.mutations.setSignupData(state, null);
t.deepEqual(state.signupData, DEFAULT_STATE.signupData);
});
test('actions -> authenticate wrong', async t => {
const state = storeMock.state();
const { commit, dispatch } = factoryCommitDispacth(state);
state.loginData.email = 'invalid@email.com';
state.loginData.password = USER_DATA.pasword;
await storeMock.actions.authenticate({ commit, state, dispatch })
.catch(() => {
t.is(state.jwt, '');
t.is(state.loginData.error, 'invalid email');
});
});
test('actions -> authenticate ok', async t => {
const state = storeMock.state();
const { commit, dispatch } = factoryCommitDispacth(state);
state.loginData.email = USER_DATA.email;
state.loginData.password = USER_DATA.pasword;
await storeMock.actions.authenticate({ commit, state, dispatch })
.then(() => {
t.is(state.jwt, TOKEN);
t.is(state.loginData.error, null);
});
});
test('actions -> logout', t => {
const state = storeMock.state();
const { commit, dispatch } = factoryCommitDispacth(state);
storeMock.actions.logout({ commit, state, dispatch });
t.is(state.jwt, '');
t.deepEqual(state.user, DEFAULT_STATE.user);
});
test('actions -> createAccount wrong', async t => {
const state = storeMock.state();
const { commit, dispatch } = factoryCommitDispacth(state);
await storeMock.actions.createAccount({ commit, state, dispatch })
.catch(() => {
t.is(state.jwt, '');
t.is(state.signupData.error, 'you need name, email and password');
});
});
test('actions -> createAccount ok', async t => {
const state = storeMock.state();
const { commit, dispatch } = factoryCommitDispacth(state);
state.signupData.name = USER_DATA.name;
state.signupData.email = USER_DATA.email;
state.signupData.password = USER_DATA.password;
await storeMock.actions.createAccount({ commit, state, dispatch })
.then(() => {
t.is(state.jwt, TOKEN);
t.deepEqual(state.user, {
...USER_DATA,
user_id: 'user_id'
});
t.is(state.signupData.error, null);
});
});
test('actions -> setJwtAndGetUser', async t => {
const state = storeMock.state();
const { commit, dispatch } = factoryCommitDispacth(state);
await storeMock.actions.setJwtAndGetUser({ commit, state, dispatch }, TOKEN)
.then(() => {
t.is(state.jwt, TOKEN);
t.deepEqual(state.user, {
...USER_DATA,
user_id: 'user_id'
});
});
});
test('actions -> setJwt', async t => {
const state = storeMock.state();
const { commit, dispatch } = factoryCommitDispacth(state);
await storeMock.actions.updateJwt({ commit, state, dispatch }, TOKEN)
.then(() => {
t.is(state.jwt, TOKEN);
t.deepEqual(state.user, {});
});
});
Ahora corremos un lindo
yarn test
Y
Todo debe fallar en el store (casi al menos)
const ERROR_INVALID_PARAMETERS_ON_FACTORY = 'The store factory need add login function, signup function and getProfile function';
export default function factory (loginFunc , signupFunc, getProfileFunc) {
if (typeof loginFunc !== 'function' || typeof signupFunc !== 'function' || typeof getProfileFunc !== 'function') {
throw new Error(ERROR_INVALID_PARAMETERS_ON_FACTORY);
}
return {
plugins: [],
state: intitialState,
actions: {
authenticate,
updateJwt,
setJwtAndGetUser,
createAccount,
logout
},
mutations: {
setJwt,
setUser,
setLoginData,
setSignupData
},
getters: {}
};
// MUTATIONS
function setJwt (state, jwt) {
jwt = (jwt !== null) ? jwt : intitialState().jwt;
state.jwt = jwt;
}
function setUser (state, user) {
user = (user !== null) ? user : intitialState().user;
Object.assign(state.user, user);
}
function setLoginData (state, data) {
data = (data !== null) ? data : intitialState().loginData;
Object.assign(state.loginData, data);
}
function setSignupData (state, data) {
data = (data != null) ? data : intitialState().signupData;
Object.assign(state.signupData, data);
}
// ACTIONS
function authenticate ({ commit, state }) {
return new Promise((resolve, reject) => {
commit('setLoginData', { loading: true });
return loginFunc(state.loginData.email, state.loginData.password)
.then((jwt) => {
commit('setLoginData', intitialState().loginData);
commit('setJwt', jwt);
resolve(jwt);
})
.catch((error) => {
const message = (error && error.message) ? error.message : error;
commit('setLoginData', { loading: false, error: message });
reject(error);
});
});
}
function updateJwt ({ commit }, token) {
return Promise.resolve(commit('setJwt', token));
}
function setJwtAndGetUser ({ commit }, token = null) {
commit('setJwt', token);
return getProfileFunc(token)
.then((user) => {
return commit('setUser', user);
});
}
function createAccount ({ commit, state, dispatch }) {
return new Promise((resolve, reject) => {
commit('setSignupData', { loading: true });
const data = {
name: state.signupData.name,
email: state.signupData.email,
password: state.signupData.password
};
const loginData = {
email: state.signupData.email,
password: state.signupData.password
};
return signupFunc(data)
.then(user => commit('setUser', user))
.then(() => commit('setLoginData', loginData))
.then(() => dispatch('authenticate'))
.then(() => commit('setJwt', state.jwt))
.then(() => commit('setSignupData', null))
.then(resolve)
.catch((error) => {
const message = (error && error.message) ? error.message : error;
commit('setSignupData', { loading: false, error: message });
reject(error);
});
});
}
function logout ({ commit }) {
commit('setJwt', null);
return Promise.resolve(commit('setUser', null));
}
}
function intitialState () {
return Object.assign({}, {
jwt: '',
user: {},
loginData: {
email: '',
password: '',
error: null,
loading: false
},
signupData: {
name: '',
email: '',
password: '',
error: null,
loading: false
}
});
}
Tip 3
Probemos componentes Chicos
import Vue from 'vue/dist/vue.common';
import test from 'ava';
import Login from '../../src/components/login.vue';
import mount from '../helpers/vue.helpers';
import * as AuthStore from '../../src/components/store.babel';
const USER_DATA = {
name: 'Sergio Marin',
email: 'higher.vnf@gmail.com',
password: 'supersecreto'
};
const TOKEN = 'TOKEN_MOCK';
const storeMock = AuthStore.default(
(email, password) => {
if (!email && !password) {
return Promise.reject(new Error('invalid credentials'));
} else if (email === 'invalid@email.com') {
return Promise.reject(new Error('invalid email'));
} else {
return Promise.resolve(TOKEN);
}
},
({name, email, password}) => {
if (!email || !password || !name) {
return Promise.reject(new Error('you need name, email and password'));
} else if (email === 'invalid@email.com') {
return Promise.reject(new Error('invalid email'));
} else {
return Promise.resolve({user_id: 'user_id', name, email, password});
}
},
() => {
return Promise.resolve({
...USER_DATA,
user_id: 'user_id'
});
}
);
test('Renders', t => {
const store = AuthStore.default(() => {}, () => {}, () => {});
const { vm } = mount(Login, store);
t.is(vm.$el.textContent.trim(), 'Login');
});
test('Login need email and password', async t => {
const { vm } = mount(Login, storeMock);
const emailInput = vm.$el.querySelector('input[name="email"]');
const passwordInput = vm.$el.querySelector('input[name="password"]');
const form = vm.$el.getElementsByTagName('form')[0];
const event = new window.Event('submit', {
'bubbles' : true, // Whether the event will bubble up through the DOM or not
'cancelable' : true // Whether the event may be canceled or not
});
const changeEvnt = new window.Event('input', {
'bubbles' : true, // Whether the event will bubble up through the DOM or not
'cancelable' : true // Whether the event may be canceled or not
});
emailInput.value = USER_DATA.email;
emailInput.dispatchEvent(changeEvnt);
await Vue.nextTick()
.then(() => {
t.deepEqual(vm.$store.state.user, {});
t.is(vm.$store.state.jwt, '');
t.is(vm.$el.textContent.trim(), 'Login');
form.dispatchEvent(event);
return Vue.nextTick();
})
.then(() => {
t.is(vm.$store.state.loginData.email, USER_DATA.email);
t.not(vm.$store.state.loginData.error, null);
t.deepEqual(vm.$store.state.user, {});
passwordInput.value = USER_DATA.password;
passwordInput.dispatchEvent(changeEvnt);
return Vue.nextTick();
})
.then(() => {
form.dispatchEvent(event);
return Vue.nextTick();
})
.then(() => {
t.is(vm.$store.state.loginData.error, null);
t.deepEqual(vm.$store.state.user, {
...USER_DATA,
user_id: 'user_id'
});
});
});
<template>
<section>
<p v-if="error" class="help is-danger">
{{error}}
</p>
<form v-on:submit.prevent="create">
<div class="field">
<p class="control has-icons-left has-icons-right">
<input name="email" v-model="email" class="input" type="email" placeholder="Email">
<span class="icon is-small is-left">
<i class="fa fa-envelope"></i>
</span>
<span class="icon is-small is-right">
<i class="fa fa-check"></i>
</span>
</p>
</div>
<div class="field">
<p class="control has-icons-left">
<input name="password" v-model="password" class="input" type="password" placeholder="Password">
<span class="icon is-small is-left">
<i class="fa fa-lock"></i>
</span>
</p>
</div>
<div class="field">
<p class="control">
<button type="submit" class="button is-success submit-form" v-bind:class="{ 'is-loading': loading }">Login</button>
</p>
</div>
</form>
</section>
</template>
<script>
function mapValueToStore (obj, field) {
obj[field] = {
get () {
return (!!this.storeNamespace) ?
this.$store.state[this.storeNamespace].loginData[field] :
this.$store.state.loginData[field];
},
set (value) {
const storeNamespace = (!!this.storeNamespace) ? `${this.storeNamespace}/` : '';
return this.$store.commit(`${storeNamespace}setLoginData`, { [field]: value });
}
}
return obj;
}
export default {
props: {
storeNamespace: {
type: String
},
successCb: {
type: Function,
default: (r) => { return r }
},
errorCb: {
type: Function,
default: () => {}
}
},
computed: {
...['email', 'password', 'error', 'loading'].reduce(mapValueToStore, {})
},
methods: {
create () {
const storeNamespace = (!!this.storeNamespace) ? `${this.storeNamespace}/` : '';
return new Promise((resolve, reject) => {
this.$store.dispatch(`${storeNamespace}authenticate`)
.then((jwt) => this.$store.dispatch(`${storeNamespace}setJwtAndGetUser`, jwt))
.then(this.successCb)
.then(resolve)
.catch((error) => {
this.errorCb(error);
resolve();
});
});
}
}
}
</script>
<style lang="scss">
.input {
display: block;
padding: 1.5em 1em;
width: 300px;
}
</style>
Tiene un minuto para hablar de TDD
By Sergio Marin
Tiene un minuto para hablar de TDD
- 899