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 dijeron en la pagina Meetup ...

 

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 nos sirve para contruir el backend como se podria hacer con express, pero trae out of the box soporte a sockets para cada servicio que se cree.

 

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 ningun experto en TDD

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 el jwt

  • set del user, borrar todo el user

  • set del loginData, borrar toda la data

  • set del createData, borrar el form

  • authenticar el usuario contra el API

  • crear el usuario 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

 

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