mirror of https://github.com/zulip/zulip.git
724 lines
21 KiB
JavaScript
724 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:
|
|
|
|
const util = zrequire('util');
|
|
assert(!util.find_wildcard_mentions('boring text'));
|
|
assert(util.find_wildcard_mentions('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 `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.
|
|
|
|
const 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,
|
|
};
|
|
|
|
// 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', {});
|
|
|
|
zrequire('stream_data');
|
|
|
|
run_test('stream_data', () => {
|
|
assert.equal(stream_data.get_sub_by_name('Denmark'), undefined);
|
|
stream_data.add_sub(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('stream_topic_history');
|
|
|
|
// And finally require the module that we will test directly:
|
|
zrequire('message_store');
|
|
|
|
run_test('message_store', () => {
|
|
const in_message = { ...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 = stream_topic_history.get_recent_topic_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);
|
|
|
|
const in_message = { ...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.
|
|
|
|
// 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
|
|
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_by_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() {
|
|
const 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('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 current_message_list containing messages that
|
|
// can be marked read
|
|
set_global('current_msg_list', {
|
|
all_messages: () => test_messages,
|
|
can_mark_messages_read: () => true,
|
|
});
|
|
|
|
// 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.
|
|
let channel_post_opts;
|
|
channel.post = (opts) => {
|
|
channel_post_opts = opts;
|
|
};
|
|
|
|
// First, test for a message list that cannot read messages
|
|
current_msg_list.can_mark_messages_read = () => false;
|
|
unread_ops.process_visible();
|
|
assert.deepEqual(channel_post_opts, undefined);
|
|
|
|
current_msg_list.can_mark_messages_read = () => true;
|
|
// 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_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.
|
|
const 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();
|
|
|
|
let 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;
|
|
|
|
let topic_list_cleared;
|
|
topic_list.clear = () => {
|
|
topic_list_cleared = true;
|
|
};
|
|
|
|
let topic_list_closed;
|
|
topic_list.close = () => {
|
|
topic_list_closed = true;
|
|
};
|
|
|
|
let topic_list_rebuilt;
|
|
topic_list.rebuild = () => {
|
|
topic_list_rebuilt = true;
|
|
};
|
|
|
|
return {
|
|
verify_actions: () => {
|
|
assert(topic_list_cleared);
|
|
assert(topic_list_closed);
|
|
assert(topic_list_rebuilt);
|
|
},
|
|
};
|
|
}
|
|
|
|
function make_sidebar_helper() {
|
|
let 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();
|
|
});
|