zulip/frontend_tests/node_tests/general.js

718 lines
21 KiB
JavaScript

// This is a general tour of how to write node tests that
// may also give you some quick insight on how the Zulip
// browser app is constructed. Let's start with testing
// a function from util.js.
//
// The most basic unit tests load up code, call functions,
// and assert truths:
zrequire('util');
assert(!util.is_all_or_everyone_mentioned('boring text'));
assert(util.is_all_or_everyone_mentioned('mention @**everyone**'));
// Let's test with people.js next. We'll show this technique:
// * get a false value
// * change the data
// * get a true value
zrequire('people');
const isaac = {
email: 'isaac@example.com',
user_id: 30,
full_name: 'Isaac Newton',
};
assert(!people.is_known_user_id(isaac.user_id));
people.add(isaac);
assert(people.is_known_user_id(isaac.user_id));
// The global.people object is a very fundamental object in the
// Zulip app. You can learn a lot more about it by reading
// the tests in people.js in the same directory as this file.
// Let's create the current user, which some future tests will
// require.
var me = {
email: 'me@example.com',
user_id: 31,
full_name: 'Me Myself',
};
people.add(me);
people.initialize_current_user(me.user_id);
// Let's look at stream_data next, and we will start by putting
// some data at module scope (since it may be useful for future
// tests):
const denmark_stream = {
color: 'blue',
name: 'Denmark',
stream_id: 101,
subscribed: false,
};
// We often use IIFEs (immediately invoked function expressions)
// to make our tests more self-containted.
zrequire('stream_data');
run_test('stream_data', () => {
assert.equal(stream_data.get_sub_by_name('Denmark'), undefined);
stream_data.add_sub('Denmark', denmark_stream);
const sub = stream_data.get_sub_by_name('Denmark');
assert.equal(sub.color, 'blue');
});
// Hopefully the basic patterns for testing data-oriented modules
// are starting to become apparent. To reinforce that, we will present
// few more examples that also expose you to some of our core
// data objects. Also, we start testing some objects that have
// deeper dependencies.
const messages = {
isaac_to_denmark_stream: {
id: 400,
sender_id: isaac.user_id,
stream_id: denmark_stream.stream_id,
type: 'stream',
flags: ['has_alert_word'],
topic: 'copenhagen',
// note we don't have every field that a "real" message
// would have, and that can be fine
},
};
// We are going to test a core module called messages_store.js. It
// depends on some code that we aren't really interested in testing,
// so let's create some stub functions that do nothing.
const noop = () => undefined;
set_global('alert_words', {});
alert_words.process_message = noop;
// We can also bring in real code:
zrequire('recent_senders');
zrequire('unread');
zrequire('topic_data');
// And finally require the module that we will test directly:
zrequire('message_store');
run_test('message_store', () => {
// Our test runner automatically sets _ for us.
// See http://underscorejs.org/ for help on that library.
var in_message = _.clone(messages.isaac_to_denmark_stream);
assert.equal(in_message.alerted, undefined);
message_store.set_message_booleans(in_message);
assert.equal(in_message.alerted, true);
// Let's add a message into our message_store via
// add_message_metadata.
assert.equal(message_store.get(in_message.id), undefined);
message_store.add_message_metadata(in_message);
const message = message_store.get(in_message.id);
assert.equal(message, in_message);
// There are more side effects.
const topic_names = topic_data.get_recent_names(denmark_stream.stream_id);
assert.deepEqual(topic_names, ['copenhagen']);
});
// Tracking unread messages is a very fundamental part of the Zulip
// app, and we use the unread object to track unread messages.
run_test('unread', () => {
const stream_id = denmark_stream.stream_id;
const topic_name = 'copenhagen';
assert.equal(unread.num_unread_for_topic(stream_id, topic_name), 0);
var in_message = _.clone(messages.isaac_to_denmark_stream);
message_store.set_message_booleans(in_message);
unread.process_loaded_messages([in_message]);
assert.equal(unread.num_unread_for_topic(stream_id, topic_name), 1);
});
// In the Zulip app you can narrow your message stream by topic, by
// sender, by PM recipient, by search keywords, etc. We will discuss
// narrows more broadly, but first let's test out a core piece of
// code that makes things work.
// Some quick housekeeping: Let's clear page_params, which is a data
// structure that the server sends down to us when the app starts. We
// prefer to test with a clean slate.
set_global('page_params', {});
// We use the second argument of zrequire to find the location of the
// Filter class.
zrequire('Filter', 'js/filter');
run_test('filter', () => {
const filter_terms = [
{operator: 'stream', operand: 'Denmark'},
{operator: 'topic', operand: 'copenhagen'},
];
const filter = new Filter(filter_terms);
const predicate = filter.predicate();
// We don't need full-fledged messages to test the gist of
// our filter. If there are details that are distracting from
// your test, you should not feel guilty about removing them.
assert.equal(predicate({type: 'personal'}), false);
assert.equal(predicate({
type: 'stream',
stream_id: denmark_stream.stream_id,
topic: 'does not match filter',
}), false);
assert.equal(predicate({
type: 'stream',
stream_id: denmark_stream.stream_id,
topic: 'copenhagen',
}), true);
});
// We have a "narrow" abstraction that sits roughly on top of the
// "filter" abstraction. If you are in a narrow, we track the
// state with the narrow_state module.
zrequire('narrow_state');
run_test('narrow_state', () => {
// As we often do, first make assertions about the starting
// state:
assert.equal(narrow_state.stream(), undefined);
// Now set the state.
const filter_terms = [
{operator: 'stream', operand: 'Denmark'},
{operator: 'topic', operand: 'copenhagen'},
];
const filter = new Filter(filter_terms);
narrow_state.set_current_filter(filter);
assert.equal(narrow_state.stream(), 'Denmark');
assert.equal(narrow_state.topic(), 'copenhagen');
});
/*
Let's step back and review what we've done so far.
We've used fairly straightforward testing techniques
to explore the following modules:
filter
message_store
narrow_state
people
stream_data
util
We haven't gone deep on any of these objects, but if
you are interested, all of these objects have test
suites that have 100% line coverage on the modules
that implement those objects. For example, you can look
at people.js in this directory for more tests on the
people object.
We can quickly review some testing concepts:
zrequire - bring in real code
set_global - create stubs
IIFE - enclose tests in their own scope
assert.equal - verify results
------
It's time to elaborate a bit on set_global.
First, some context. When we test certain objects,
we don't always want to test all the code they
depend on. Often we want to completely ignore the
interactions with certain objects; other times, we
will want to simulate some behavior of the objects
we depend on without bringing in all the implementation
details.
Also, our test runner runs many tests back to back.
Between each test we need to essentially reset the global
object back to its original state, so that state doesn't
leak between tests.
That's where set_global comes in. When you call
set_global, it updates the global namespace with an
object that you specify in the **test**, not real
code. Using set_global explicitly tells your test
reader what your testing boundaries are between "real"
code and "simulated" code. Finally, and perhaps most
importantly, the test runner will prevent this state
from leaking into the next test (and "zrequire" has
the same behavior attached to it as well).
------
Let's talk about our next steps.
An app is pretty useless without an actual data source.
One of the primary ways that a Zulip client gets data
is through events. (We also get data at page load, and
we can also ask the server for data, but that's not in
the scope of this conversation yet.)
Chat systems are dynamic. If an admin adds a user, or
if a user sends a messages, the server immediately sends
events to all clients so that they can reflect appropriate
changes in their UI. We're not going to discuss the entire
"full stack" mechanism here. Instead, we'll focus on
the client code, starting at the boundary where we
process events.
Let's just get started...
*/
zrequire('server_events_dispatch');
// We will use Bob in several tests.
const bob = {
email: 'bob@example.com',
user_id: 33,
full_name: 'Bob Roberts',
};
run_test('add_user_event', () => {
const event = {
type: 'realm_user',
op: 'add',
person: bob,
};
assert(!people.is_known_user_id(bob.user_id));
server_events_dispatch.dispatch_normal_event(event);
assert(people.is_known_user_id(bob.user_id));
});
/*
It's actually a little surprising that adding a user does
not have side effects beyond the people object. I guess
we don't immediately update the buddy list, but that's
because the buddy list gets updated on the next server
fetch.
Let's try an update next. To make this work, we will want
to put some stub objects into the global namespace (as
opposed to using the "real" code).
*/
set_global('activity', {});
set_global('message_live_update', {});
set_global('pm_list', {});
set_global('settings_users', {});
zrequire('user_events');
run_test('update_user_event', () => {
const new_bob = {
email: 'bob@example.com',
user_id: bob.user_id,
full_name: 'The Artist Formerly Known as Bob',
};
const event = {
type: 'realm_user',
op: 'update',
person: new_bob,
};
// We have to stub a few things:
activity.redraw = noop;
message_live_update.update_user_full_name = noop;
pm_list.update_private_messages = noop;
settings_users.update_user_data = noop;
// Dispatch the realm_user/update event, which will update
// data structures and have other side effects that are
// stubbed out above.
server_events_dispatch.dispatch_normal_event(event);
const user = people.get_person_from_user_id(bob.user_id);
// Verify that the code actually did its main job:
assert.equal(user.full_name, 'The Artist Formerly Known as Bob');
});
/*
Our test verifies that the update events leads to a name change
inside the people object, but it obviously kind of glosses over
the other interactions.
We can go a step further and verify the sequence of of operations
that happen during an event. This concept is called "mocking",
and you can find libraries to help do mocking. Here we will
just build our own lightweight mocking system, which is almost
trivially easy to do in a language like Javascript.
*/
function test_helper() {
var events = [];
return {
redirect: (module_name, func_name) => {
const full_name = module_name + '.' + func_name;
global[module_name][func_name] = () => {
events.push(full_name);
};
},
events: events,
};
}
/*
Our test_helper will allow us to redirect methods to an
events array, and we can then later verify that the sequence
of side effect is as predicted.
(Note that for now we don't simulate return values nor do we
inspect the arguments to these functions. We could easily
extend our helper to do more.)
The forthcoming example is a pretty extreme example, where we
are calling a pretty high level method that dispatches
a lot of its work out to other objects.
*/
set_global('home_msg_list', {});
set_global('message_list', {});
set_global('message_util', {});
set_global('notifications', {});
set_global('resize', {});
set_global('stream_list', {});
set_global('unread_ops', {});
set_global('unread_ui', {});
zrequire('message_events');
run_test('insert_message', () => {
const helper = test_helper();
const new_message = {
sender_id: isaac.user_id,
id: 1001,
content: 'example content',
};
assert.equal(message_store.get(new_message.id), undefined);
helper.redirect('activity', 'process_loaded_messages');
helper.redirect('message_util', 'add_new_messages');
helper.redirect('notifications', 'received_messages');
helper.redirect('resize', 'resize_page_components');
helper.redirect('stream_list', 'update_streams_sidebar');
helper.redirect('unread_ops', 'process_visible');
helper.redirect('unread_ui', 'update_unread_counts');
narrow_state.reset_current_filter();
message_events.insert_new_messages([new_message]);
// Even though we have stubbed a *lot* of code, our
// tests can still verify the main "narrative" of how
// the code invokes various objects when a new message
// comes in:
assert.deepEqual(helper.events, [
'message_util.add_new_messages',
'message_util.add_new_messages',
'activity.process_loaded_messages',
'unread_ui.update_unread_counts',
'resize.resize_page_components',
'unread_ops.process_visible',
'notifications.received_messages',
'stream_list.update_streams_sidebar',
]);
// Despite all of our stubbing/mocking, the call to
// insert_new_messages will have created a very important
// side effect that we can verify:
const inserted_message = message_store.get(new_message.id);
assert.equal(inserted_message.id, new_message.id);
assert.equal(inserted_message.content, 'example content');
});
/*
The previous example starts to get us out of the data layer of
the app and into more interesting interactions.
When a new message comes in, we update the three major
panes of the app:
* left sidebar - stream list
* middle pane - message view
* right sidebar - buddy list (aka "activity" list)
These are reflected by the following calls:
stream_list.update_streams_sidebar
message_util.add_new_messages
activity.process_loaded_messages
For now, though, let's focus on another side effect
of processing incoming messages:
unread_ops.process_visible
When new messages come in, they are often immediately
visible to users, so the app will communicate this
back to the server by calling unread_ops.process_visible.
In order to unit test this, we don't want to require
an actual server to be running. Instead, this example
will stub many of the "boundaries" to focus on the
core behavior.
*/
set_global('channel', {});
set_global('feature_flags', {});
set_global('home_msg_list', {});
set_global('message_list', {});
set_global('message_viewport', {});
zrequire('message_flags');
zrequire('unread_ops');
run_test('unread_ops', () => {
(function set_up() {
const test_messages = [
{
id: 50,
type: 'stream',
stream_id: denmark_stream.stream_id,
topic: 'copenhagen',
unread: true,
},
];
// Make our test message appear to be unread, so that
// we then need to subsequently process them as read.
unread.process_loaded_messages(test_messages);
// Make our window appear visible.
notifications.window_has_focus = () => true;
// Make our "test" message appear visible.
message_viewport.bottom_message_visible = () => true;
// Make us not be in a narrow (somewhat hackily).
message_list.narrowed = undefined;
set_global('current_msg_list', {
all_messages: () => test_messages,
});
// Ignore these interactions for now:
home_msg_list.show_message_as_read = noop;
message_list.all = {};
message_list.all.show_message_as_read = noop;
notifications.close_notification = noop;
}());
// Set up a way to capture the options passed in to channel.post.
var channel_post_opts;
channel.post = (opts) => {
channel_post_opts = opts;
};
// Do the main thing we're testing!
unread_ops.process_visible();
// The most important side effect of the above call is that
// we post info to the server. We can verify that the correct
// url and parameters are specified:
assert.deepEqual(channel_post_opts, {
url: '/json/messages/flags',
idempotent: true,
data: { messages: '[50]', op: 'add', flag: 'read' },
success: channel_post_opts.success,
});
});
/*
Next we will explore this function:
stream_list.update_streams_sidebar
To make this test work, we will create a somewhat elaborate
function that fills in for jQuery (https://jquery.com/), so that
one boundary of our tests is how stream_list.js calls into
stream_list to manipulate DOM.
*/
set_global('topic_list', {});
zrequire('stream_sort');
zrequire('stream_list');
const social_stream = {
color: 'red',
name: 'Social',
stream_id: 102,
subscribed: true,
};
run_test('set_up_filter', () => {
stream_data.add_sub('Social', social_stream);
const filter_terms = [
{operator: 'stream', operand: 'Social'},
{operator: 'topic', operand: 'lunch'},
];
const filter = new Filter(filter_terms);
narrow_state.filter = () => filter;
narrow_state.active = () => true;
});
function jquery_elem() {
// We create basic stubs for jQuery elements, so they
// just work. We can extend these in cases where we want
// more detailed testing.
var elem = {};
elem.expectOne = () => elem;
elem.removeClass = () => elem;
elem.empty = () => elem;
return elem;
}
function make_jquery_helper() {
const stream_list_filter = jquery_elem();
stream_list_filter.val = () => '';
const stream_filters = jquery_elem();
var appended_data;
stream_filters.append = function (data) {
appended_data = data;
};
function fake_jquery(selector) {
switch (selector) {
case '.stream-list-filter':
return stream_list_filter;
case 'ul#stream_filters li':
return jquery_elem();
case '#stream_filters':
return stream_filters;
default:
throw Error('unknown selector: ' + selector);
}
}
set_global('$', fake_jquery);
return {
verify_actions: () => {
const expected_data_to_append = [
[
'stream stub',
],
];
assert.deepEqual(appended_data,
expected_data_to_append);
},
};
}
function make_topic_list_helper() {
// We want to make sure that updating a stream_list
// closes the topic list and then rebuilds it. We don't
// care about the implementation details of topic_list for
// now, just that it is invoked properly.
topic_list.active_stream_id = () => undefined;
topic_list.get_stream_li = () => undefined;
var topic_list_closed;
topic_list.close = () => {
topic_list_closed = true;
};
var topic_list_rebuilt;
topic_list.rebuild = () => {
topic_list_rebuilt = true;
};
return {
verify_actions: () => {
assert(topic_list_closed);
assert(topic_list_rebuilt);
},
};
}
function make_sidebar_helper() {
var updated_whether_active;
function row_widget() {
return {
update_whether_active: () => {
updated_whether_active = true;
},
get_li: () => ['stream stub'],
};
}
stream_list.stream_sidebar.set_row(social_stream.stream_id, row_widget());
stream_list.stream_cursor = {
redraw: noop,
};
return {
verify_actions: () => {
assert(updated_whether_active);
},
};
}
zrequire('topic_zoom');
run_test('stream_list', () => {
const jquery_helper = make_jquery_helper();
const sidebar_helper = make_sidebar_helper();
const topic_list_helper = make_topic_list_helper();
// This is what we are testing!
stream_list.update_streams_sidebar();
jquery_helper.verify_actions();
sidebar_helper.verify_actions();
topic_list_helper.verify_actions();
});