• בלוג
  • יישומי רדוקס נהנים גם מבדיקות יחידה קלות יותר

יישומי רדוקס נהנים גם מבדיקות יחידה קלות יותר

פוסט זה כולל טיפ קצר בנושא פיתוח Front End. אם אתם רוצים ללמוד יותר לעומק על פיתוח Front End מהבסיס ועד הנושאים המתקדמים תשמחו לשמוע שבניתי קורס וידאו מקיף בנושא זה הכולל מעל 50 שיעורי וידאו והמון תרגול מעשי.
למידע נוסף והצטרפות לקורס בקרו בדף קורס Front End באתר.
 

קיימות שתי בעיות שתמיד חוזרות בבדיקות יחידה שאני כותב. הראשונה נוגעת לשאלה מה לבדוק, שהרי בדיקת פונקציות קטנות מדי היא בזבוז זמן, ובדיקת התנהגויות מורכבות קשה ליישום ותחזוקה בבדיקות יחידה (ומתאימה יותר לבדיקות אינטגרציה). למצוא את נקודת האמצע זה לא פשוט, ובחלק מהמערכות גם כמעט בלתי אפשרי.
הבעיה השניה נוגעת לקשר בין הבדיקות ו״ניקוי״ המערכת בין בדיקה לבדיקה כך שלא תהיה השפעה לסדר הבדיקות על התוצאה. עדכונים של מידע גלובלי המשותף למספר מנגנונים מסתתרים בקוד תמים למראה ולא נדיר למצוא קוד בדיקות בו כששמים בדיקה אחת בהערה מייצרים כשלון בשלושה מקומות שונים ולא קשורים.

ואז מגיעה רדוקס ומציעה ארכיטקטורת קוד שכמעט ומדלגת על שתי בעיות אלו. בואו נראה איך זה קורה.

1. הרעיון: תכנות פונקציונאלי

אתחיל בהבהרה: זה לא פוסט על רדוקס ואם אתם לא מכירים כלל את הספריה אני ממליץ ללמוד עליה קצת (למשל בקורס שיש לנו בנושא). ועכשיו לעניינינו, ברדוקס כל דבר מעניין שקורה ביישום מתבצע באותו האופן: אנו יוצרים אוביקט ״פעולה״ שמתאר מה רוצים לעשות, ומעבירים אותו לפונקציית Reducer. פונקציה זו מקבלת אוביקט מצב שמתאר את מצב היישום הנוכחי ואוביקט פעולה שמתאר מה צריך לעשות ותפקידה להעביר את היישום למצב הבא, כלומר אחרי ביצוע הפעולה.

בפוסט זה אבחן דוגמא פשוטה של יישום המנהל אוסף של רכיבים, נקרא להם Widgets. אפשר להוסיף רכיב, לשנות שם של רכיב או למחוק רכיב מהמערכת. פונקציית ה Reducer יכולה להיראות כך:

function reducer(state = initialState, action) {
  if (!action) {
    return state;
  }

  var idx;
  switch (action.type) {
    case ADD_NEW:
      let new_widget = make_widget(state, action.payload);
      return state.push(new_widget);

    case RENAME:
      idx = find_index(state, action.meta.id);
      var new_name = action.payload;
      return state.setIn([idx, 'name'], new_name);

    case REMOVE:
      idx = find_index(state, action.meta.id);
      return state.delete(idx);

    default:
      return state;
  }
}

רק הרעיון הזה מספיק בשביל לפתור לנו את שתי הבעיות שהתחלנו אתן את הפוסט. מה לבדוק? את הפעולות השונות שה Reducer מטפל בהן. כבונוס, פונקציית Reducer מקבלת שני אוביקטים ומחזירה אוביקט מצב חדש, ואין לה אינטרקציה עם שום דבר מלבד שני אוביקטים אלו, ה State וה Action. מסיבה זו הבדיקות לעולם לא ישפיעו אחת על השניה.

2. קוד הבדיקות

בשביל לקבל את המצב ההתחלתי אפשר להפעיל את reducer בלי פרמטרים. לאחר מכן נעביר לפונקציה פעולות כדי להגיע למצב אותו אנו רוצים לבדוק. בבדיקה הבאה אפשר לבצע את אותו הדבר, שהרי כל המידע נשמר במשתנים פנימיים של הבדיקה.

כך נראה קוד הבדיקות עבור ה Reducer שהוצג בסעיף הקודם:

'use strict';

var expect = require('chai').expect;

var actions = require('store/widgets').actions;
var reducer = require('store/widgets').reducer;

describe('Widgets', function() {
  describe('Adding Widgets', function() {
    it('should start empty', function() {
      var state = emptyState();
      expect(state.count()).to.eq(0);
    });

    it('should have one item after adding', function() {
      var state = emptyState();
      var next_state = reducer(state, actions.add_new('test', 'DummyWidget'));
      expect(next_state.count()).to.eq(1);
    });

    it('should assign new ids', function() {
      var state = emptyState();
      var next_state = reducer(state, actions.add_new('test', 'DummyWidget'));
      expect(next_state.first().get('id')).not.to.be.undefined; // eslint-disable-line
    });

    it('should assign numeric ids', function() {
      var state = stateWithItems('foo', 'bar', 'buz');

      state.forEach(function(item) {
        expect(typeof id(item)).to.eq('number');
      });
    });
  });

  describe('Renaming Widgets', function() {
    it('should have the new name after rename', function() {
      var state = stateWithItems('test');
      var _id = id(state.first());
      const newname = 'newname';

      var next_state = reducer(state, actions.rename(_id, newname));
      expect(name(next_state.first())).to.eq(newname);
    });
  });

  describe('Removing Widgets', function() {
    it('should remove an item from the list', function() {
      var state = stateWithItems('foo', 'bar', 'buz');
      var _id = id(state.get(1));
      var next_state = reducer(state, actions.remove(_id));
      expect(next_state.count()).to.eq(2);
    });

    it('should remove the specified item', function() {
      var state = stateWithItems('foo', 'bar', 'buz');
      var _id = id(state.get(1));
      var next_state = reducer(state, actions.remove(_id));

      expect(next_state.find(w => w.get('id') === _id)).to.be.undefined; // eslint-disable-line
    });
  });
});

/**
* Utility Functions
 */
function emptyState() {
  return reducer();
}

function stateWithItems() {
  var state = emptyState();
  for (var i = 0; i < arguments.length; i++) {
    state = reducer(state, actions.add_new(arguments[i], 'DummyWidget'));
  }
  return state;
}

function id(item) {
  return item.get('id');
}

function name(item) {
  return item.get('name');
}

3. האם זה הכל?

כמובן שלא. נדבך נוסף בארכיטקטורה יהיה כתיבת קוד התקשורת עם API בתוך Actions והפעלתו דרך Middlewares. זהו קוד חיצוני ל Reducer ולכן אי אפשר לבדוק אותו באותה הפשטות בה אנו בודקים Reducers. בבדיקה של Action תצטרכו לדמות את צד השרת למשל באמצעות sinon, ולבדוק שאכן הפונקציה שולחת את הבקשות הנכונות. אבל על זה נצטרך להרחיב בפוסט נפרד.

ככל שנכתוב חלק גדול יותר מהקוד בתוך המנגנון של Reducers כך יהיה לנו קל יותר לבדוק אותו ולזהות טעויות כבר בשבלבי הפיתוח.